diff --git a/.dockerignore b/.dockerignore index 25672b03f..72a0b6b6a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,8 @@ uploads chart/ docs/ docker-compose.yml +docker-compose.minio-test.yml +minio-licence.key unraid-template.xml *.sqlite *.sqlite-shm diff --git a/.gitattributes b/.gitattributes index 3a9e6105b..b88429c87 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ *.sh text eol=lf # Binary files — no line ending conversion *.png binary +*.webp binary *.jpg binary *.jpeg binary *.gif binary diff --git a/.github/workflows/enforce-target-branch.yml b/.github/workflows/enforce-target-branch.yml index 36f127d81..2b41b2ec4 100644 --- a/.github/workflows/enforce-target-branch.yml +++ b/.github/workflows/enforce-target-branch.yml @@ -68,6 +68,38 @@ jobs: return; } + // Dependabot cannot be pointed at dev. `target-branch` in dependabot.yml + // applies to version updates only; security updates always open against + // the default branch, and security updates are all this repository gets + // (there is no dependabot.yml). Labelling them meant close-stale-wrong-branch + // shut them 24 hours later, which is how 13 of them died. Retarget instead: + // a bot cannot click "Edit" the way the comment below asks a human to. + if (context.payload.pull_request.user.login === 'dependabot[bot]') { + try { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + base: 'dev', + }); + if (labels.includes('wrong-base-branch')) { + await github.rest.issues.removeLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + name: 'wrong-base-branch', + }); + } + console.log('Retargeted the Dependabot PR onto dev.'); + return; + } catch (err) { + // Retargeting can fail when the branch was cut from main and dev has + // moved on. Fall through to the normal path so a human sees it rather + // than the check passing on a PR that is still aimed at main. + console.log(`Could not retarget the Dependabot PR: ${err.message}`); + } + } + // Base is main — check if this user is a maintainer let permission = 'none'; try { diff --git a/.github/workflows/lint-prettier.yml b/.github/workflows/lint-prettier.yml index 41c746117..b5677fc63 100644 --- a/.github/workflows/lint-prettier.yml +++ b/.github/workflows/lint-prettier.yml @@ -2,6 +2,11 @@ name: Lint & Prettier on: pull_request: branches: [main, dev] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: lint: runs-on: ubuntu-latest diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 5b2cfe08b..c522f18c1 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -9,6 +9,10 @@ on: permissions: pull-requests: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: scout: # Docker Hub secrets are not exposed to pull requests from forks, so the diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20f1864b2..6bd407d47 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,18 +1,115 @@ -name: Tests +name: Tests & SonarQube permissions: contents: read +# The repo's test/quality CI and SonarQube Cloud analysis in one workflow, so +# each test suite runs once per event instead of once for the old test.yml and +# once more for Sonar's coverage. Pushes to dev and main produce branch +# analysis; pull requests get PR analysis with decoration on the PR itself +# (the scanner picks the context up from the Actions environment). +# +# Job layout — three tiers: +# - Preflight: plugin-facts, i18n-parity and the quality jobs +# (server-quality, client-quality, plugin-sdk-quality) hold the cheap +# generated-code/typecheck/lint/smoke gates. Every test job `needs:` all +# of them, so a gate failure stops the run before the expensive coverage +# fan-out spends ~15 runner-minutes on a result the gates already sank. +# - Tests: the *-coverage jobs run the suites and upload lcov; s3-contract +# runs the MinIO contract suite. Coverage jobs run tests only — with the +# gates cleared in preflight, they can only fail because tests failed. +# - Scan `needs:` exactly the four coverage jobs. A coverage job failing +# must stop the scan — a missing lcov file would make every line of that +# package report as uncovered, and the dashboard would show a collapse +# that did not happen. (The preflight gates stop it transitively.) +# - Fork PRs don't receive secrets, so the two jobs that need them skip +# themselves: s3-contract (MINIO_LICENSE_KEY) and scan (SONAR_TOKEN). +# Everything else runs on fork PRs so outside contributors still get the +# full test signal. +# +# Automatic Analysis must stay disabled on sonarcloud.io — it is mutually +# exclusive with CI-based analysis, and it cannot import the lcov coverage +# this workflow produces. +# A push or PR that touches none of these paths can't change the tests or the +# analysis, so don't spend the coverage fan-out on it. For a docs-only push +# that means no run and no fresh branch analysis — harmless, since the last +# analysis is still accurate for unchanged code. GitHub Actions doesn't +# support YAML anchors, so the two lists are literal copies: keep them +# identical when editing either one. on: + push: + branches: [main, dev] + paths: + - 'server/**' + - 'client/**' + - 'shared/**' + - 'plugin-sdk/**' + # npm workspaces hoist every dependency into the single root lockfile, + # so a dependency bump touches none of the four directories above — and + # a native binding like better-sqlite3 is exactly what needs the suite. + - 'package.json' + - 'package-lock.json' + - 'sonar-project.properties' + - '.github/workflows/test.yml' + # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect + # neither the tests nor the scan — no test reads checked-in markdown + # and SonarCloud has no markdown analyzer. Negations must stay last. + - '!**/*.md' pull_request: branches: [main, dev] paths: - 'server/**' - 'client/**' - 'shared/**' + - 'plugin-sdk/**' + # npm workspaces hoist every dependency into the single root lockfile, + # so a dependency bump touches none of the four directories above — and + # a native binding like better-sqlite3 is exactly what needs the suite. + - 'package.json' + - 'package-lock.json' + - 'sonar-project.properties' - '.github/workflows/test.yml' + # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect + # neither the tests nor the scan — no test reads checked-in markdown + # and SonarCloud has no markdown analyzer. Negations must stay last. + - '!**/*.md' + workflow_dispatch: + +# A newer push to the same branch/PR supersedes the running one — cancel it +# rather than spending the coverage fan-out on a stale commit. Safe here +# because the Sonar scan job runs last and only the latest commit matters. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: + plugin-facts: + name: Plugin Permission Facts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + # 22 matches the TREK server runtime (same choice as the plugin-sdk + # jobs and publish-plugin-sdk.yml). + node-version: 22 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --workspace server + + # The plugin permission facts live once, in + # server/src/nest/plugins/protocol/envelope.ts. Every other copy — the + # SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is + # generated from it and checked in, because publish-plugin-sdk builds + # from a standalone checkout with no server present. This fails the run + # if a copy drifts, if a new permission has no admin.plugins.perm.* + # translation, or if the CLI has no hint for it. + - name: Check the generated plugin facts are in sync + run: cd server && npm run check:plugin-facts + i18n-parity: name: i18n Key Parity runs-on: ubuntu-latest @@ -27,32 +124,9 @@ jobs: - name: Check i18n key parity run: node shared/scripts/i18n-parity.mjs --strict - shared-contracts: - name: Shared Contracts (Zod) + server-quality: + name: Server Types & Lint runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v6 - - - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: npm - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci --workspace shared - - - name: Typecheck - run: cd shared && npm run typecheck - - - name: Run tests - run: cd shared && npm test - - server-tests: - name: Server Tests - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v6 @@ -65,40 +139,40 @@ jobs: - name: Install dependencies run: npm ci - - name: Ensure @swc/core's Linux binary for unplugin-swc - # The lockfile was generated on Windows and omits @swc/core's Linux - # optional native binary, so npm ci/install skips it on the runner. - # Install the matching version explicitly so the server's SWC transform - # (server/vitest.config.ts) can load. - run: | - SWC_VERSION=$(node -p "require('@swc/core/package.json').version") - npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" - - name: Build shared run: npm run build --workspace=shared + - name: Typecheck shared + # Rides along here rather than in shared-coverage: this job already + # pays the root install and shared build, and a coverage job may only + # fail because tests failed (it gates the scan). + run: cd shared && npm run typecheck + - name: Build server (tsc -> dist) run: cd server && npm run build + - name: Smoke production require chain + # Vitest aliases the MCP SDK deep imports, so only this exercises what + # production runs: the built dist resolving the MCP SDK's subpath + # exports through the tsconfig-paths/register runtime hook. + run: cd server && node --require tsconfig-paths/register -e "require('./dist/nest-mcp')" + - name: Typecheck run: cd server && npm run typecheck + - name: Typecheck tests + # tests/ is outside the build tsconfig, so a service that grows a + # constructor parameter does not break its hand-wired call sites at + # compile time: the argument arrives as undefined and throws at + # runtime, if anything reads it at all. This is the step that + # catches it. + run: cd server && npm run typecheck:tests + - name: Lint run: cd server && npm run lint:check - - name: Run tests - run: cd server && npm run test:coverage - - - name: Upload coverage - if: success() - uses: actions/upload-artifact@v6 - with: - name: backend-coverage - path: server/coverage/ - retention-days: 7 - - client-tests: - name: Client Tests + client-quality: + name: Client Types & Lint runs-on: ubuntu-latest steps: @@ -125,13 +199,287 @@ jobs: - name: Page pattern check run: cd client && npm run lint:pages - - name: Run tests + plugin-sdk-quality: + name: Plugin SDK Types + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugin-sdk + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + + # plugin-sdk is standalone (own lockfile, not a root workspace), so it + # installs from its own directory. + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + s3-contract: + name: S3 Driver Contract (MinIO) + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + # Fork PRs don't get repo secrets, so MINIO_LICENSE_KEY would be empty and + # the AIStor container would fail to boot. Pushes and manual dispatches do + # have secrets, so only fork PRs are filtered out. (The implicit success() + # on this expression also keeps the job skipped when a preflight fails.) + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Ensure @swc/core's Linux binary for unplugin-swc + # The lockfile was generated on Windows and omits @swc/core's Linux + # optional native binary, so npm ci/install skips it on the runner. + # Install the matching version explicitly so the server's SWC transform + # (server/vitest.config.ts) can load. + run: | + SWC_VERSION=$(node -p "require('@swc/core/package.json').version") + npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" + + - name: Build shared + run: npm run build --workspace=shared + + - name: Write MinIO license + run: printf '%s' "${{ secrets.MINIO_LICENSE_KEY }}" > minio-licence.key + + - name: Start MinIO + # `services:` can't pass the `server /data --license ...` command to + # the AIStor image, so it starts via docker run. 127.0.0.1 endpoint is + # load-bearing: aws-lite picks path-style only for IP/localhost hosts, + # which is all stock MinIO/AIStor speaks. + run: | + docker run -d --name minio -p 127.0.0.1:9000:9000 \ + -v "$PWD/minio-licence.key:/minio.license:ro" \ + -e MINIO_ROOT_USER=trekci -e MINIO_ROOT_PASSWORD=trekci-secret \ + quay.io/minio/aistor/minio:RELEASE.2026-08-07T18-34-35Z server /data --license /minio.license + + - name: Wait for MinIO + # curl was removed from the AIStor image — poll from the runner instead. + # 60 x 2s gives a wider margin on cold runners than the 30 x 1s the + # pre-6fbff835 job used. + run: | + for i in $(seq 1 60); do + curl -sf http://127.0.0.1:9000/minio/health/live && exit 0 + sleep 2 + done + echo "MinIO never became healthy" >&2 + docker logs minio >&2 + exit 1 + + - name: Run the S3 driver contract suite + # No --coverage: the src/nest/storage/** 94/84/97/94 pin must keep + # holding from hermetic tests alone. The suite's beforeAll creates + # the trek-test bucket idempotently — no separate bucket-init step. + env: + TREK_TEST_S3_ENDPOINT: http://127.0.0.1:9000 + TREK_TEST_S3_BUCKET: trek-test + TREK_TEST_S3_ACCESS_KEY_ID: trekci + TREK_TEST_S3_SECRET_ACCESS_KEY: trekci-secret + run: cd server && npx vitest run tests/unit/nest/storage/s3.driver.contract.test.ts + + server-coverage: + name: Server Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Ensure @swc/core's Linux binary for unplugin-swc + # The lockfile was generated on Windows and omits @swc/core's Linux + # optional native binary, so npm ci/install skips it on the runner. + # Install the matching version explicitly so the server's SWC transform + # (server/vitest.config.ts) can load. + run: | + SWC_VERSION=$(node -p "require('@swc/core/package.json').version") + npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION" + + - name: Build shared + run: npm run build --workspace=shared + + - name: Run tests with coverage + run: cd server && npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-server-coverage + path: server/coverage/ + retention-days: 1 + + client-coverage: + name: Client Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + # Only the shared and client workspaces — a full root npm ci is not + # needed, and the scoped install keeps @testing-library/dom (a peer of + # @testing-library/react) in the tree without the re-resolution dance + # the folded coverage job used to need. + - name: Install dependencies + run: npm ci --workspace shared && npm ci --workspace client + + - name: Build shared + run: npm run build --workspace=shared + + - name: Run tests with coverage run: cd client && npm run test:coverage - name: Upload coverage - if: success() uses: actions/upload-artifact@v6 with: - name: frontend-coverage + name: sonar-client-coverage path: client/coverage/ - retention-days: 7 + retention-days: 1 + + shared-coverage: + name: Shared Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci --workspace shared + + - name: Run tests with coverage + run: cd shared && npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-shared-coverage + path: shared/coverage/ + retention-days: 1 + + plugin-sdk-coverage: + name: Plugin SDK Coverage + runs-on: ubuntu-latest + needs: [plugin-facts, i18n-parity, server-quality, client-quality, plugin-sdk-quality] + defaults: + run: + working-directory: plugin-sdk + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + # 22 matches the TREK server runtime and has node:sqlite, which the + # dev-server tests exercise (same choice as publish-plugin-sdk.yml). + node-version: 22 + + # plugin-sdk is standalone (own lockfile, not a root workspace), so it + # installs from its own directory. + - name: Install dependencies + run: npm ci + + - name: Run tests with coverage + run: npm run test:coverage + + - name: Upload coverage + uses: actions/upload-artifact@v6 + with: + name: sonar-plugin-sdk-coverage + path: plugin-sdk/coverage/ + retention-days: 1 + + scan: + name: Scan + runs-on: ubuntu-latest + # Fork PRs don't receive SONAR_TOKEN — skip the scan rather than fail it. + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + # Coverage jobs failing must stop the scan: a missing lcov file would make + # every line of that package report as uncovered, and the dashboard would + # show a collapse that did not happen. The preflight gates already stop it + # transitively through these four, so they aren't repeated here. + needs: [server-coverage, client-coverage, shared-coverage, plugin-sdk-coverage] + + steps: + - uses: actions/checkout@v6 + with: + # Sonar reads git blame to date each issue and to decide what counts + # as new code. A shallow clone leaves it blind to both. + fetch-depth: 0 + + - name: Download server coverage + uses: actions/download-artifact@v6 + with: + name: sonar-server-coverage + path: server/coverage/ + + - name: Download client coverage + uses: actions/download-artifact@v6 + with: + name: sonar-client-coverage + path: client/coverage/ + + - name: Download shared coverage + uses: actions/download-artifact@v6 + with: + name: sonar-shared-coverage + path: shared/coverage/ + + - name: Download plugin-sdk coverage + uses: actions/download-artifact@v6 + with: + name: sonar-plugin-sdk-coverage + path: plugin-sdk/coverage/ + + - name: Scan + uses: SonarSource/sonarqube-scan-action@v8 + env: + # SonarQube Cloud is the scan action's default host, so no + # SONAR_HOST_URL — the token is a sonarcloud.io token for the + # liketrek org. + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + with: + # New Code is configured as "previous version", so this string is what + # draws that line — changing it re-bases every "new code" metric. + # Pinned rather than read from package.json on purpose: package.json is + # still on 3.4.1 while the project has been analysed as 4.0.0 all along, + # and taking the lower number would silently move the baseline. Bump it + # here as part of the release, together with the version bump. + # + # Everything else — sources, tests, exclusions, the documented + # false-positive list — lives in sonar-project.properties so it stays + # reviewable in a PR rather than buried in workflow YAML. + args: -Dsonar.projectVersion=4.0.0 diff --git a/.gitignore b/.gitignore index a83917a19..c3aaa38a2 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ yarn-error.log* # Coverage coverage +coverage-*/ *.lcov .nyc_output @@ -64,5 +65,8 @@ coverage .scannerwork test-data +# Live-MinIO/AIStor manual test license key — never commit, operators obtain their own free key +minio-licence.key + .run .full-review diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..9fa2d44b9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What TREK is + +A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces: + +- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run. +- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime. +- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB. + +**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root. + +Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture. + +## Commands + +Run from the repo root unless noted. **Use `npm run ... --workspace=`, not `npm -w `**. + +```bash +npm run dev # build shared, then watch shared + server + client concurrently +npm run build # build shared → server → client (order matters) +npm run lint # lint all three workspaces +npm run format # prettier --write all three +``` + +Tests: + +```bash +npm run test # all workspaces +npm run test --workspace=server # vitest run (server) +npm run test:unit --workspace=server # tests/unit only +npm run test:integration --workspace=server +npm run test:ws --workspace=server # websocket tests +npm run test:e2e # server e2e (boots Nest against temp SQLite) +npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk +cd client && npm run test # client vitest run +cd client && npm run e2e # Playwright +cd client && npm run lint:pages # enforce the Page pattern (see below) +cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset) +npm run typecheck --workspace=server # tsc --noEmit (also client/shared) +``` + +Run a single test file or test: + +```bash +npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/) +npx vitest run -t "name of the test" # by test-name pattern +``` + +i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys): + +```bash +npm run i18n:parity --workspace=shared # audit (exit 0) +npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift) +``` + +Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`). + +## Server architecture + +Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest//`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`). + +- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`). +- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route. +- **Per-domain module shape** (one folder under `server/src/nest//`): `.controller.ts` + `.service.ts` + `.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src//.schema.ts`. +- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module. +- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler). +- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB. +- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`. +- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`. +- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections). +- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring. + +## Client architecture + +Offline-first, with a layered data flow. A Page never owns state directly: + +- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook. +- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state. +- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles. +- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment. +- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons. + +## Shared contracts & i18n + +- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src//`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up. +- **i18n locale files live in `shared/src/i18n//`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys. + +## Project direction + +These principles come out of a verified 2026 full-repo audit and shape how all new code should be written: + +- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger. +- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper. +- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip. +- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state. +- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery. +- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`. + +## Conventions (from CONTRIBUTING.md) + +- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes). +- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed. +- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR. +- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages. +- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%). +- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes. + +## Reference docs + +- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e). diff --git a/Dockerfile b/Dockerfile index ba5a9c4c0..aa603f0d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,11 @@ COPY package.json package-lock.json ./ COPY shared/package.json ./shared/ COPY server/package.json ./server/ +# The trailing chown runs in this layer on purpose: it covers the manifests and +# the freshly installed node_modules while they are already part of this layer's +# changeset, so it costs nothing. Everything copied after this point carries +# --chown=node:node for the same reason — a recursive chown in a later layer +# would copy up every inode it touches and duplicate the whole tree in the image. RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ libkitinerary-bin && \ @@ -53,7 +58,8 @@ RUN apt-get update && \ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ apt-get purge -y python3 build-essential && \ apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx + rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ + chown -R node:node /app # gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node. COPY --from=gosu-build /out/gosu /usr/local/bin/gosu @@ -65,34 +71,42 @@ ENV QT_QPA_PLATFORM=offscreen # Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere. ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor -COPY --from=server-builder /app/server/dist ./server/dist +COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist # Runtime data assets read from server/assets at runtime: airports.json (flight # transport search) and atlas/*.geojson.gz (Atlas country/region map). The build # only emits dist, so these must be copied explicitly or the features silently # degrade to empty in the image. -COPY --from=server-builder /app/server/assets ./server/assets +COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets # The in-app help pages (/help) read this straight from disk at runtime, so the # docs always match the version running. Without it, wikiService falls back to # fetching the GitHub wiki, which tracks main and needs network access. -COPY wiki ./wiki +COPY --chown=node:node wiki ./wiki # tsconfig-paths/register reads this at runtime to resolve MCP SDK paths. -COPY server/tsconfig.json ./server/ +COPY --chown=node:node server/tsconfig.json ./server/ # Encryption-key rotation is run on demand via tsx (a prod dep) straight from the # raw .ts source — it never enters dist, so it must be copied in explicitly or # `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found. -COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts +COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts # Admin recovery script (node server/reset-admin.js) for locked-out installs. -COPY server/reset-admin.js ./server/reset-admin.js -COPY --from=shared-builder /app/shared/dist ./shared/dist -COPY --from=client-builder /app/client/dist ./server/public -COPY --from=client-builder /app/client/public/fonts ./server/public/fonts +COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js +COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist +COPY --chown=node:node --from=client-builder /app/client/dist ./server/public +COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts -RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \ +# journey/ and places/ must be listed here and in server/src/index.ts (#1762) — +# a dir created lazily on first upload needs write permission on the uploads +# mount point itself, which fails with EACCES when the bind-mounted host dir +# isn't writable by node. Only paths this layer creates are chowned; anything +# already in the image arrived node-owned via --chown above. +RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \ + /app/uploads/photos /app/uploads/journey /app/uploads/places && \ ln -s /app/uploads /app/server/uploads && \ ln -s /app/data /app/server/data && \ - chown -R node:node /app + chown -R node:node /app/data /app/uploads && \ + chown -h node:node /app/server/uploads /app/server/data ENV NODE_ENV=production +ENV NODE_USE_ENV_PROXY=1 ENV PORT=3000 ARG APP_VERSION=dev ENV APP_VERSION=${APP_VERSION} diff --git a/MCP.md b/MCP.md index 01de46f48..e0114af4f 100644 --- a/MCP.md +++ b/MCP.md @@ -1,577 +1,3 @@ # MCP Integration -TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI -assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a -structured API. - -> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available. - -## Table of Contents - -- [Setup](#setup) - - [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended) - - [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated) -- [Authentication](#authentication) -- [OAuth Scopes](#oauth-scopes) -- [Limitations & Important Notes](#limitations--important-notes) -- [Resources (read-only)](#resources-read-only) -- [Tools (read-write)](#tools-read-write) - - [Compound Tools](#compound-tools) -- [Prompts](#prompts) -- [Example](#example) - ---- - -## Setup - -### 1. Enable the MCP addon (admin) - -An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp` -endpoint returns `404` and the MCP section does not appear in user settings. - -### 2. Connect your MCP client - -#### Option A: OAuth 2.1 (recommended) - -MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token -management required — just provide the server URL: - -```json -{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "https://your-trek-instance.com/mcp" - ] - } - } -} -``` - -> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows). - -**What happens automatically:** -1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint. -2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata. -3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591). -4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant. -5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed. - -> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth -> discovery to work correctly. - -**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in -**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret -(`trekcs_` prefix) and fixed scopes that you define up front. - -#### Option B: Static API Token (deprecated) - -> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above. - -1. Go to **Settings > Integrations > MCP** and create an API token. -2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once. -3. Add it to your `claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "trek": { - "command": "npx", - "args": [ - "mcp-remote", - "https://your-trek-instance.com/mcp", - "--header", - "Authorization: Bearer trek_your_token_here" - ] - } - } -} -``` - -Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a -static token will receive deprecation warnings in the AI client via server instructions and tool results. - -Each user can create up to **10 static tokens**. - ---- - -## Authentication - -TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients. - -| Method | Token prefix | Access level | TTL | Notes | -|--------|-------------|-------------|-----|-------| -| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. | -| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. | -| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. | - -All methods require the `Authorization: Bearer ` header (strict scheme enforcement — `Bearer` required). - ---- - -## OAuth Scopes - -When connecting via OAuth 2.1, you grant specific scopes during the consent step. TREK registers only the MCP tools -that match your granted scopes for that session. - -| Scope | Permission | Group | -|-------|-----------|-------| -| `trips:read` | View trips & itineraries | Trips | -| `trips:write` | Edit trips & itineraries | Trips | -| `trips:delete` | Delete trips (irreversible) | Trips | -| `trips:share` | Manage share links | Trips | -| `places:read` | View places & map data | Places | -| `places:write` | Manage places | Places | -| `atlas:read` | View Atlas | Atlas | -| `atlas:write` | Manage Atlas | Atlas | -| `packing:read` | View packing lists | Packing | -| `packing:write` | Manage packing lists | Packing | -| `todos:read` | View to-do lists | To-dos | -| `todos:write` | Manage to-do lists | To-dos | -| `budget:read` | View budget | Budget | -| `budget:write` | Manage budget | Budget | -| `reservations:read` | View reservations | Reservations | -| `reservations:write` | Manage reservations | Reservations | -| `collab:read` | View collaboration | Collaboration | -| `collab:write` | Manage collaboration | Collaboration | -| `notifications:read` | View notifications | Notifications | -| `notifications:write` | Manage notifications | Notifications | -| `vacay:read` | View vacation plans | Vacation | -| `vacay:write` | Manage vacation plans | Vacation | -| `geo:read` | Maps & geocoding | Geo | -| `weather:read` | Weather forecasts | Weather | -| `journey:read` | View journeys | Journey | -| `journey:write` | Manage journeys | Journey | -| `journey:share` | Manage journey share links | Journey | - -**Scope rules:** -- A `:write` scope implies `:read` access for the same group (e.g. `budget:write` also grants budget read access). -- Any `trips:*` scope (`trips:read`, `trips:write`, `trips:delete`, or `trips:share`) grants trip read access. -- Any `journey:*` scope (`journey:read`, `journey:write`, or `journey:share`) grants journey read access. -- `list_trips` and `get_trip_summary` are **always available** regardless of scopes — they are navigation tools. -- Static tokens and web session JWTs have full access to all tools (equivalent to all scopes). -- Addon-gated tools (Atlas, Collab, Vacay, Journey) require both the relevant scope **and** the addon to be enabled. - ---- - -## Limitations & Important Notes - -| Limitation | Details | -|-----------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| -| **Admin activation required** | The MCP addon must be enabled by an admin before any user can access it. | -| **Per-user scoping** | Each MCP session is scoped to the authenticated user. You can only access trips you own or are a member of. | -| **No image uploads** | Cover images cannot be set through MCP. Use the web UI to upload trip covers. | -| **Reservations are created as pending** | When the AI creates a reservation, it starts with `pending` status. You must confirm it manually or ask the AI to set the status to `confirmed`. | -| **Demo mode restrictions** | If TREK is running in demo mode, all write operations through MCP are blocked. | -| **Rate limiting** | 300 requests per minute per user (configurable via `MCP_RATE_LIMIT`). Exceeding this returns a `429` error. | -| **Per-client rate limiting** | Rate limits are tracked per user-client pair, so each OAuth client has its own independent rate limit window. | -| **Session limits** | Maximum 20 concurrent MCP sessions per user (configurable via `MCP_MAX_SESSION_PER_USER`). Sessions expire after 1 hour of inactivity. | -| **Token limits** | Maximum 10 static API tokens per user. Maximum 10 OAuth clients per user. | -| **Token revocation** | Deleting a static token or revoking an OAuth session immediately terminates all active MCP sessions for that token/client. | -| **OAuth scope enforcement** | Only tools matching your granted OAuth scopes are registered in the session. Calling an out-of-scope tool returns an error. | -| **Addon toggle invalidation** | When an admin enables or disables an addon, all active MCP sessions are invalidated and must be re-established. | -| **Real-time sync** | Changes made through MCP are broadcast to all connected clients in real-time via WebSocket, just like changes made through the web UI. | -| **Addon-gated features** | Some resources and tools are only available when the corresponding addon (Atlas, Collab, Vacay, Journey) is enabled by an admin. | - ---- - -## Resources (read-only) - -Resources provide read-only access to your TREK data. MCP clients can read these to understand the current state before -making changes. - -### Core Resources - -| Resource | URI | Description | -|-----------------------|-------------------------------------------------|---------------------------------------------------------------------------------------| -| Trips | `trek://trips` | All trips you own or are a member of | -| Trip Detail | `trek://trips/{tripId}` | Single trip with metadata and member count | -| Days | `trek://trips/{tripId}/days` | Days of a trip with their assigned places | -| Places | `trek://trips/{tripId}/places` | All places/POIs saved in a trip. Supports `?assignment=all\|unassigned\|assigned` | -| Budget | `trek://trips/{tripId}/budget` | Budget and expense items | -| Budget Per-Person | `trek://trips/{tripId}/budget/per-person` | Per-person totals and split breakdown | -| Budget Settlement | `trek://trips/{tripId}/budget/settlement` | Suggested transactions to settle who owes whom | -| Packing | `trek://trips/{tripId}/packing` | Packing checklist | -| Packing Bags | `trek://trips/{tripId}/packing/bags` | Packing bags with their assigned members | -| Reservations | `trek://trips/{tripId}/reservations` | Flights, hotels, restaurants, etc. | -| Day Notes | `trek://trips/{tripId}/days/{dayId}/notes` | Notes for a specific day | -| Accommodations | `trek://trips/{tripId}/accommodations` | Hotels/rentals with check-in/out details | -| Members | `trek://trips/{tripId}/members` | Owner and collaborators | -| Collab Notes | `trek://trips/{tripId}/collab-notes` | Shared collaborative notes | -| To-Dos | `trek://trips/{tripId}/todos` | To-do items ordered by position | -| Categories | `trek://categories` | Available place categories (for use when creating places) | -| Bucket List | `trek://bucket-list` | Your personal travel bucket list | -| Visited Countries | `trek://visited-countries` | Countries marked as visited in Atlas | -| Notifications | `trek://notifications/in-app` | Your in-app notifications (most recent 50, unread first) | - -### Addon-Gated Resources - -These resources are only available when the corresponding addon is enabled by an admin. - -| Resource | URI | Addon | Description | -|-----------------------|-------------------------------------------------|----------|---------------------------------------------------------------------| -| Atlas Stats | `trek://atlas/stats` | Atlas | Visited country counts and continent breakdown | -| Atlas Regions | `trek://atlas/regions` | Atlas | Manually visited sub-country regions | -| Collab Polls | `trek://trips/{tripId}/collab/polls` | Collab | All polls for a trip with vote counts per option | -| Collab Messages | `trek://trips/{tripId}/collab/messages` | Collab | Most recent 100 chat messages for a trip | -| Vacay Plan | `trek://vacay/plan` | Vacay | Full snapshot of your active vacation plan (members, years, config) | -| Vacay Entries | `trek://vacay/entries/{year}` | Vacay | All vacation day entries for the active plan and a specific year | -| Vacay Holidays | `trek://vacay/holidays/{year}` | Vacay | Public holidays for the plan's configured region and year | -| Journeys | `trek://journeys` | Journey | All journeys owned or contributed to by the current user | -| Journey Detail | `trek://journeys/{journeyId}` | Journey | Single journey with entries, contributors, and linked trips | -| Journey Entries | `trek://journeys/{journeyId}/entries` | Journey | All entries in a journey (date, text, mood, linked trip) | -| Journey Contributors | `trek://journeys/{journeyId}/contributors` | Journey | Contributors (owner and collaborators) of a journey | - ---- - -## Tools (read-write) - -TREK exposes tools organized by feature area. Use `get_trip_summary` as a starting point — it returns everything about a -trip in a single call. - -### Trip Summary - -| Tool | Description | -|--------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `get_trip_summary` | Full denormalized snapshot of a trip: metadata, members, days with assignments and notes, accommodations, budget, packing, reservations, collab notes, to-dos, and poll/message counts. Use this as your context loader. | - -### Compound Tools - -Compound tools collapse common multi-step workflows into a single atomic call. Each one wraps two sequential operations in a database transaction — if the second step fails, the first is rolled back automatically. - -> **When to use:** Only use compound tools when the place or item does not yet exist. If it already exists, call the individual tools (`assign_place_to_day`, `create_accommodation`, `set_budget_item_members`) directly. - -| Tool | Wraps | Description | -|---|---|---| -| `create_and_assign_place` | `create_place` + `assign_place_to_day` | Create a new place and immediately assign it to a specific day. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `dayId` and optional `assignment_notes`. Returns `{ place, assignment }`. | -| `create_place_accommodation` | `create_place` + `create_accommodation` | Create a new place and immediately book it as an accommodation for a date range. Accepts all `create_place` fields (`place_notes` instead of `notes`) plus `start_day_id`, `end_day_id`, `check_in`, `check_out`, `confirmation`, and `accommodation_notes`. Also auto-creates a linked hotel reservation. Returns `{ place, accommodation }`. | -| `create_budget_item_with_members` | `create_budget_item` + `set_budget_item_members` | Create a budget item and optionally set which members are splitting it. Accepts all `create_budget_item` fields plus an optional `userIds` array. If `userIds` is omitted or empty, behaves identically to `create_budget_item`. Returns `{ item }` with members populated. | - -**Scope requirements** match the underlying tools: `places:write` for `create_and_assign_place`, `trips:write` for `create_place_accommodation`, `budget:write` for `create_budget_item_with_members` (Budget addon required). - ---- - -### Trips - -| Tool | Description | -|----------------------|---------------------------------------------------------------------------------------------| -| `list_trips` | List all trips you own or are a member of. Supports `include_archived` flag. | -| `create_trip` | Create a new trip with title, dates, currency. Days are auto-generated from the date range. | -| `update_trip` | Update a trip's title, description, dates, or currency. | -| `delete_trip` | Delete a trip. **Owner only.** | -| `list_trip_members` | List the owner and all collaborators of a trip. | -| `add_trip_member` | Add a user to a trip by username or email. **Owner only.** | -| `remove_trip_member` | Remove a collaborator from a trip. **Owner only.** | -| `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items are reset to unchecked. | -| `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text for calendar apps. | -| `get_share_link` | Get the current public share link for a trip and its permission flags. | -| `create_share_link` | Create or update the public share link with configurable visibility flags (map, bookings, packing, budget, collab). | -| `delete_share_link` | Revoke the public share link for a trip. | - -### Places - -> To create a place and assign it to a day in one call, use [`create_and_assign_place`](#compound-tools). - -| Tool | Description | -|------------------|--------------------------------------------------------------------------------------------------| -| `list_places` | List places/POIs in a trip, optionally filtered by assignment status, category, tag, or search. | -| `create_place` | Add a place/POI with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id` for opening hours. | -| `update_place` | Update any field of an existing place including transport mode, timing, and price. | -| `delete_place` | Remove a place from a trip. | -| `bulk_delete_places` | Delete multiple places at once by ID. Removes all day assignments as well. **Cannot be undone.** | -| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. | -| `list_categories` | List all available place categories with id, name, icon and color. | -| `search_place` | Search for a real-world place by name or address. Returns `osm_id` and `google_place_id` for use in `create_place`. | - -### Day Planning - -| Tool | Description | -|-----------------------------|--------------------------------------------------------------------------------------| -| `update_day` | Set or clear a day's title (e.g. "Arrival in Paris", "Free day"). | -| `create_day` | Add a new day to a trip with optional date and notes. | -| `delete_day` | Delete a day from a trip. | -| `assign_place_to_day` | Pin a place to a specific day in the itinerary. | -| `unassign_place` | Remove a place assignment from a day. | -| `reorder_day_assignments` | Reorder places within a day by providing assignment IDs in the desired order. | -| `update_assignment_time` | Set start/end times for a place assignment (e.g. "09:00" – "11:30"). Pass `null` to clear. | -| `move_assignment` | Move a place assignment to a different day. | -| `get_assignment_participants`| Get the list of users participating in a specific place assignment. | -| `set_assignment_participants`| Set participants for a place assignment (replaces current list). | - -### Accommodations - -> To create a place and book it as an accommodation in one call, use [`create_place_accommodation`](#compound-tools). - -| Tool | Description | -|------------------------|------------------------------------------------------------------------------------------| -| `create_accommodation` | Add an accommodation (hotel, Airbnb, etc.) linked to a place and a check-in/out date range. | -| `update_accommodation` | Update fields on an existing accommodation (dates, times, confirmation, notes). | -| `delete_accommodation` | Delete an accommodation record from a trip. | - -### Transport - -Transport bookings (flights, trains, cars, cruises) support multi-stop `endpoints[]` — each endpoint has a `role` (`from`/`to`/`stop`), name, optional IATA `code` (for flights), coordinates, timezone, and local time. Use `search_airports` to resolve airport names to IATA codes before creating a flight. - -| Tool | Description | -|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional endpoints, departure/arrival times, and confirmation details. Created as pending. | -| `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace the full stop list. Use `status: "confirmed"` to confirm. | -| `delete_transport` | Delete a transport booking from a trip. | - -### Reservations - -For flights, trains, cars, and cruises, use the **Transport** tools above. Reservations cover all other booking types. - -| Tool | Description | -|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------| -| `create_reservation` | Create a pending reservation. Supports hotels, restaurants, events, tours, activities, and other types. Hotels can be linked to places and check-in/out days. | -| `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | -| `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | -| `reorder_reservations` | Update the display order of reservations (and transports) within a day. | -| `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and associated place. | - -### Budget - -> To create a budget item and set its members in one call, use [`create_budget_item_with_members`](#compound-tools). - -| Tool | Description | -|----------------------------|---------------------------------------------------------------------------------------| -| `create_budget_item` | Add an expense with name, category, and price. | -| `update_budget_item` | Update an expense's details, split (persons/days), or notes. | -| `delete_budget_item` | Remove a budget item. | -| `set_budget_item_members` | Set which trip members are splitting a budget item (replaces current member list). | -| `toggle_budget_member_paid`| Mark or unmark a member as having paid their share of a budget item. | - -### Packing - -| Tool | Description | -|-------------------------------|-----------------------------------------------------------------------------------| -| `create_packing_item` | Add an item to the packing checklist with optional category. | -| `update_packing_item` | Rename an item or change its category. | -| `toggle_packing_item` | Check or uncheck a packing item. | -| `delete_packing_item` | Remove a packing item. | -| `reorder_packing_items` | Set the display order of packing items within a trip. | -| `bulk_import_packing` | Import multiple packing items at once from a list (with optional quantity). | -| `apply_packing_template` | Apply a saved packing template to a trip (adds items from the template). | -| `save_packing_template` | Save the current packing list as a reusable template. | -| `list_packing_bags` | List all packing bags for a trip. | -| `create_packing_bag` | Create a new packing bag (e.g. "Carry-on", "Checked bag"). | -| `update_packing_bag` | Rename or recolor a packing bag. | -| `delete_packing_bag` | Delete a packing bag (items are unassigned, not deleted). | -| `set_bag_members` | Assign trip members to a packing bag. | -| `get_packing_category_assignees` | Get which trip members are assigned to each packing category. | -| `set_packing_category_assignees` | Assign trip members to a packing category. | - -### Day Notes - -| Tool | Description | -|-------------------|------------------------------------------------------------------------| -| `create_day_note` | Add a note to a specific day with optional time label and emoji icon. | -| `update_day_note` | Edit a day note's text, time, or icon. | -| `delete_day_note` | Remove a note from a day. | - -### To-Dos - -| Tool | Description | -|-------------------------------|---------------------------------------------------------------------------------------------------| -| `list_todos` | List all to-do items for a trip, ordered by position. | -| `create_todo` | Create a to-do item with name, category, due date, description, assignee, and priority. | -| `update_todo` | Update an existing to-do item. Pass `null` to clear nullable fields. | -| `toggle_todo` | Mark a to-do item as done or undone. | -| `delete_todo` | Delete a to-do item. | -| `reorder_todos` | Reorder to-do items within a trip by providing a new ordered list of IDs. | -| `get_todo_category_assignees` | Get the default assignees configured per to-do category for a trip. | -| `set_todo_category_assignees` | Set default assignees for a to-do category. Pass an empty array to clear. | - -### Tags - -| Tool | Description | -|--------------|--------------------------------------------------------------------------| -| `list_tags` | List all tags belonging to the current user. | -| `create_tag` | Create a new tag (user-scoped label for places) with optional hex color. | -| `update_tag` | Update the name or color of an existing tag. | -| `delete_tag` | Delete a tag (removes it from all places it was attached to). | - -### Notifications - -| Tool | Description | -|---------------------------------|------------------------------------------------------| -| `list_notifications` | List in-app notifications with pagination and unread filter. | -| `get_unread_notification_count` | Get the count of unread in-app notifications. | -| `mark_notification_read` | Mark a single notification as read. | -| `mark_notification_unread` | Mark a single notification as unread. | -| `mark_all_notifications_read` | Mark all notifications as read. | - -### Maps & Weather - -| Tool | Description | -|-----------------------|-----------------------------------------------------------------------------------------------------| -| `search_place` | Search for a real-world place by name/address and get coordinates, `osm_id`, and `google_place_id`. | -| `get_place_details` | Fetch detailed information (hours, photos, ratings) about a place by its Google Place ID. | -| `reverse_geocode` | Get a human-readable address for given coordinates. | -| `resolve_maps_url` | Resolve a Google Maps share URL to coordinates and place name. | -| `get_weather` | Get weather forecast for a location and date. | -| `get_detailed_weather`| Get hourly/detailed weather forecast for a location and date. | - -### Airports - -| Tool | Description | -|-------------------|-------------------------------------------------------------------------------------------------------------------| -| `search_airports` | Search for airports by name, city, or IATA code. Returns IATA code, name, city, country, coordinates, timezone. | -| `get_airport` | Look up a single airport by IATA code (e.g. `"ZRH"`, `"AMS"`, `"CDG"`). | - -### Collab Notes _(Collab addon required)_ - -| Tool | Description | -|----------------------|-------------------------------------------------------------------------------------------------| -| `create_collab_note` | Create a shared note visible to all trip members. Supports title, content, category, and color. | -| `update_collab_note` | Edit a collab note's content, category, color, or pin status. | -| `delete_collab_note` | Delete a collab note. | - -### Collab Polls & Chat _(Collab addon required)_ - -| Tool | Description | -|-----------------------|------------------------------------------------------------------------------------------| -| `list_collab_polls` | List all polls for a trip. | -| `create_collab_poll` | Create a new poll with a question, options, optional multiple choice, and deadline. | -| `vote_collab_poll` | Vote on a poll option (or remove vote if already voted). | -| `close_collab_poll` | Close a poll so no more votes can be cast. | -| `delete_collab_poll` | Delete a poll and all its votes. | -| `list_collab_messages`| List chat messages for a trip (most recent 100, supports pagination via `before`). | -| `send_collab_message` | Send a chat message to a trip's collab channel, with optional reply threading. | -| `delete_collab_message`| Delete a chat message (own messages only). | -| `react_collab_message`| Toggle a reaction emoji on a chat message. | - -### Bucket List _(Atlas addon required)_ - -| Tool | Description | -|---------------------------|--------------------------------------------------------------------------------------------| -| `create_bucket_list_item` | Add a destination to your personal bucket list with optional coordinates and country code. | -| `delete_bucket_list_item` | Remove an item from your bucket list. | - -### Atlas _(Atlas addon required)_ - -| Tool | Description | -|--------------------------|---------------------------------------------------------------------------------| -| `mark_country_visited` | Mark a country as visited using its ISO 3166-1 alpha-2 code (e.g. "FR", "JP"). | -| `unmark_country_visited` | Remove a country from your visited list. | - -### Atlas Extended _(Atlas addon required)_ - -| Tool | Description | -|----------------------------|------------------------------------------------------------------------------| -| `get_atlas_stats` | Get atlas statistics — visited country counts, region counts, continent breakdown. | -| `list_visited_regions` | List all manually visited sub-country regions for the current user. | -| `mark_region_visited` | Mark a sub-country region as visited (e.g. ISO code "US-CA"). | -| `unmark_region_visited` | Remove a region from the visited list. | -| `get_country_atlas_places` | Get places saved in the user's atlas for a specific country. | -| `update_bucket_list_item` | Update a bucket list item (name, notes, coordinates, target date). | - -### Vacay _(Vacay addon required)_ - -| Tool | Description | -|----------------------------|---------------------------------------------------------------------------------------| -| `get_vacay_plan` | Get the current user's active vacation plan (own or joined). | -| `update_vacay_plan` | Update vacation plan settings (weekend blocking, holidays, carry-over). | -| `set_vacay_color` | Set the current user's color in the vacation plan calendar. | -| `get_available_vacay_users`| List users who can be invited to the current vacation plan. | -| `send_vacay_invite` | Invite a user to join the vacation plan by their user ID. | -| `accept_vacay_invite` | Accept a pending invitation to join another user's vacation plan. | -| `decline_vacay_invite` | Decline a pending vacation plan invitation. | -| `cancel_vacay_invite` | Cancel an outgoing invitation (owner cancels an invite they sent). | -| `dissolve_vacay_plan` | Dissolve the shared plan — all members return to their own individual plan. | -| `list_vacay_years` | List calendar years tracked in the current vacation plan. | -| `add_vacay_year` | Add a calendar year to the vacation plan. | -| `delete_vacay_year` | Remove a calendar year from the vacation plan. | -| `get_vacay_entries` | Get all vacation day entries for the active plan and a specific year. | -| `toggle_vacay_entry` | Toggle a day on or off as a vacation day for the current user. | -| `toggle_company_holiday` | Toggle a date as a company holiday for the whole plan. | -| `get_vacay_stats` | Get vacation statistics for a specific year (days used, remaining, carried over). | -| `update_vacay_stats` | Update the vacation day allowance for a specific user and year. | -| `add_holiday_calendar` | Add a public holiday calendar (by region code) to the vacation plan. | -| `update_holiday_calendar` | Update label or color for a holiday calendar. | -| `delete_holiday_calendar` | Remove a holiday calendar from the vacation plan. | -| `list_holiday_countries` | List countries available for public holiday calendars. | -| `list_holidays` | List public holidays for a country and year. | - -### Journey _(Journey addon required)_ - -| Tool | Description | -|-----------------------------------|------------------------------------------------------------------------------------------------------------| -| `list_journeys` | List all journeys owned or contributed to by the current user. | -| `get_journey` | Get a full snapshot of a journey: metadata, entries, contributors, and linked trips. | -| `create_journey` | Create a new journey with title, optional subtitle, and an initial list of trip IDs. | -| `update_journey` | Update a journey's title, subtitle, or status. | -| `delete_journey` | Delete a journey. | -| `add_journey_trip` | Link an existing trip to a journey. | -| `remove_journey_trip` | Remove a trip from a journey. | -| `list_journey_entries` | List all entries in a journey (date, text, mood, linked trip). | -| `create_journey_entry` | Add an entry to a journey with optional title, body text, date, linked trip, and sort order. | -| `update_journey_entry` | Edit a journey entry's title, body, date, or mood. | -| `delete_journey_entry` | Remove an entry from a journey. | -| `reorder_journey_entries` | Reorder entries in a journey by providing the new ordered list of entry IDs. | -| `list_journey_contributors` | List the contributors of a journey (owner and invited editors/viewers). | -| `add_journey_contributor` | Invite a user to a journey with `editor` or `viewer` role. | -| `update_journey_contributor_role` | Change a contributor's role between `editor` and `viewer`. | -| `remove_journey_contributor` | Remove a contributor from a journey. | -| `update_journey_preferences` | Update display preferences for a journey (e.g. hide skeleton entries). | -| `get_journey_suggestions` | Get suggested trips to add to journeys (based on recent trip history). | -| `list_journey_available_trips` | List all trips available to the current user for linking to a journey. | -| `get_journey_share_link` | Get the current public share link for a journey. | -| `create_journey_share_link` | Create or update the public share link for a journey. | -| `delete_journey_share_link` | Revoke the public share link for a journey. | - ---- - -## Prompts - -MCP prompts are pre-built context loaders your AI client can invoke to get a structured starting point for common tasks. - -| Prompt | Description | -|----------------------|---------------------------------------------------------------------------------| -| `trip-summary` | Load a formatted summary of a trip (dates, members, days, budget, packing, reservations) before planning or modifying it. | -| `packing-list` | Get a formatted packing checklist for a trip, grouped by category. | -| `budget-overview` | Get a formatted budget summary with totals by category and per-person cost. | -| `token_auth_notice` | Static token deprecation notice and migration guide. Only available in sessions authenticated with a legacy `trek_` token. | - ---- - -## Example - -Conversation with Claude: https://claude.ai/share/51572203-6a4d-40f8-a6bd-eba09d4b009d - -Initial prompt (1st message): - -``` -I'd like to plan a week-long trip to Kyoto, Japan, arriving April 5 2027 -and leaving April 11 2027. It's cherry blossom season so please keep that -in mind when picking spots. - -Before writing anything to TREK, do some research: look up what's worth -visiting, figure out a logical day-by-day flow (group nearby spots together -to avoid unnecessary travel), find a well-reviewed hotel in a central -neighbourhood, and think about what kind of food and restaurant experiences -are worth including. - -Once you have a solid plan, write the whole thing to TREK: -- Create the trip -- Add all the places you've researched with their real coordinates -- Build out the daily itinerary with sensible visiting times -- Book the hotel as a reservation and link it properly to the accommodation days -- Add any notable restaurant reservations -- Put together a realistic budget in EUR -- Build a packing list suited to April in Kyoto -- Leave a pinned collab note with practical tips (transport, etiquette, money, etc.) -- Add a day note for each day with any important heads-up (early start, crowd - tips, booking requirements, etc.) -- Mark Japan as visited in my Atlas - -Currency: CHF. Use get_trip_summary at the end and give me a quick recap -of everything that was added. -``` - -PDF of the generated trip: [./docs/TREK-Generated-by-MCP.pdf](./docs/TREK-Generated-by-MCP.pdf) - -![trip](./docs/screenshot-trip-mcp.png) +Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information. \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md index 630a78f4d..03314d8c8 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -21,12 +21,34 @@ property names TREK consumes. Country borders (`admin0`) derive from the geoBoun CGAZ composite; sub-national regions (`admin1`) derive from the per-country open (gbOpen) release. -## OpenStreetMap — geocoding +## OpenStreetMap — geocoding, place details & descriptions -Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is +Atlas reverse-geocodes places via the **Nominatim** service, and place search, +details and descriptions come from Nominatim and **Overpass**. Data is © OpenStreetMap contributors, licensed under the Open Database License (ODbL). https://www.openstreetmap.org/copyright +## Wikimedia Commons & Wikipedia — place pictures & descriptions + +Pictures offered for a place come from **Wikimedia Commons** (geosearch by +coordinate) and are cached locally rather than hotlinked. Descriptions can come +from **Wikipedia**, resolved from the place's OpenStreetMap `wikipedia` tag. + +Commons files carry their own licence, most often **CC BY** or **CC BY-SA**. TREK +shows the author, the licence and a link to the file description page next to +every picture while it is being chosen, and keeps the author and licence with the +cached file so the credit stays visible afterwards. Wikipedia article text is +**CC BY-SA 4.0**. https://commons.wikimedia.org/wiki/Commons:Licensing + +## Google Places — optional place data + +When an administrator configures a Google Maps API key, place search, details, +pictures and editorial summaries can also come from the **Google Places API**, +subject to Google's terms. Photos carry the author attribution Google supplies +and no reusable licence; TREK displays that attribution and claims nothing +further. This path is off unless a key is configured, and each part of it has its +own switch in the admin panel. https://cloud.google.com/maps-platform/terms + ## OurAirports — airport reference data `server/assets/airports.json` is built from **OurAirports** diff --git a/README.md b/README.md index 65477543f..4ed6050db 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@
- - - TREK + + + TREK
- - Your trips. Your plan. Your server. + your trip. your plan. A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in. @@ -22,10 +21,14 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa   Docker   +Sonar Quality Gate +  +GitHub Actions Workflow Status +  Discord   Roadmap -
+  Ko-fi   BMAC @@ -34,68 +37,68 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa Latest Release Docker Pulls Stars -
---
-TREK — 60-second tour +TREK, a tour of the app

-
- Dashboard - Trip planner · day plan & route - Journey journal - Costs · expense splitting - Atlas · visited countries - Vacay planner - Collections · saved place lists - Admin panel -
- ---- - ## What you get - TREK feature tiles + + Plan, track, share
See all features +
+ +Most of what follows is an addon an admin switches on or off. Lists, Costs, Documents, Collab, Vacay and Atlas ship on; Journey, Collections, MCP, AI Parsing and AirTrail ship off and are marked below. + @@ -104,64 +107,71 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa #### 👥 Collaboration -- **Real-time sync** — WebSocket. Changes appear instantly across all connected users -- **Multi-user trips** — invite members with role-based access -- **Invite links** — one-time or reusable links with expiry -- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider -- **2FA** — TOTP + backup codes -- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable -- **Collab suite** — group chat, shared notes, polls, day check-ins +- **Real-time sync** (WebSocket): edits land live for everyone who has that trip open +- **Members**: add by email or username, hand ownership to someone else, or add guests who have no login at all +- **Permissions**: an admin maps each of 16 trip actions to admin, trip owner, trip member or everybody +- **Invite links**: one reusable link per trip with an optional expiry. Admins can also issue signup invites with a use limit that drop the new account straight into a trip +- **Public share**: a read-only trip page anyone can open without an account +- **Collab**: group chat with replies, reactions and link previews, shared notes with attachments, polls, and a What's Next list of upcoming activities. Each of the four switches on its own + +#### 📔 Journal, Atlas and Vacay + +- **Journey** (off by default): dated entries with story, mood, weather and tags, photos and video from uploads or a linked [Immich](https://immich.app) or Synology Photos library, map views, co-authors, and a public share link +- **Atlas**: mark countries and sub-national regions visited on [geoBoundaries](https://www.geoboundaries.org/) outlines, plus a bucket list, travel stats and a consecutive-years-travelled count +- **Vacay**: leave calendar with half days, public holidays from date.nager.at, school holiday overlays for 16 European countries, carry-over between years, and joint or read-only sharing of a plan +- **Collections** (off by default): a place library outside any trip, with labels, ratings, a custom image per place, copy-into-a-trip, and sharing by invite - - - @@ -171,6 +181,26 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
+
+ Atlas · dashboard · trip planner + Mobile PWA · dashboard, day plan, map, costs + Collections · journey journal · costs +
+ +
+ +## AI usage + +We use LLM-assisted coding tools across parts of this codebase. Nothing ships that a +maintainer has not read and understood: every change goes through a pull request, is +reviewed and tested, and has a human who can answer for it. "The AI wrote that" is not +an answer any of us would accept from ourselves. + +See [**How we use AI in TREK**](https://github.com/liketrek/TREK/discussions/1851) for +the details. + +
+ ## Get started in 30 seconds ```bash @@ -183,7 +213,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
-  ·  Docker Compose  ·  Helm / Kubernetes  ·  Install as PWA  ·  Reverse Proxy  ·   +Docker Compose  ·  Helm / Kubernetes  ·  Install as PWA  ·  Reverse Proxy
@@ -211,64 +241,15 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A

Docker Compose (production)

-
-Full compose example with secure defaults - -```yaml -services: - app: - image: mauriceboe/trek:latest - container_name: trek - read_only: true - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - cap_add: - - CHOWN - - SETUID - - SETGID - tmpfs: - - /tmp:noexec,nosuid,size=64m - ports: - - "3000:3000" - environment: - - NODE_ENV=production - - PORT=3000 - - ENCRYPTION_KEY=${ENCRYPTION_KEY:-} # generate with: openssl rand -hex 32 - - TZ=${TZ:-UTC} - - LOG_LEVEL=${LOG_LEVEL:-info} - - ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} - - APP_URL=${APP_URL:-} # required for OIDC + email links - # - FORCE_HTTPS=true # behind a TLS-terminating proxy - # - TRUST_PROXY=1 - # - OIDC_ISSUER=https://auth.example.com - # - OIDC_CLIENT_ID=trek - # - OIDC_CLIENT_SECRET=supersecret - # - OIDC_DISPLAY_NAME=SSO - # - OIDC_ADMIN_CLAIM=groups - # - OIDC_ADMIN_VALUE=app-trek-admins - volumes: - - ./data:/app/data - - ./uploads:/app/uploads - restart: unless-stopped - healthcheck: - test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 15s -``` - -Then: +The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml) +with secure defaults and every option documented inline. Download it, then: ```bash docker compose up -d ``` -**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work. - -
+See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose) +for the full walkthrough.
@@ -282,6 +263,8 @@ helm install trek trek/trek See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values. +
+

Install as App (PWA)

TREK works as a Progressive Web App — no App Store needed. @@ -296,36 +279,10 @@ TREK then launches fullscreen with its own icon, just like a native app. ## Updating -**Docker Compose:** +See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose, +Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note. -```bash -docker compose pull && docker compose up -d -``` - -**Docker run** — reuse the original volume paths: - -```bash -docker pull mauriceboe/trek -docker rm -f trek -docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek -``` - -> Not sure which paths you used? `docker inspect trek --format '{{json .Mounts}}'` before removing the container. - -Your data stays in the mounted `data` and `uploads` volumes — updates never touch it. - -> [!IMPORTANT] -> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved. - -

Rotating the Encryption Key

- -If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`): - -```bash -docker exec -it trek node --import tsx scripts/migrate-encryption.ts -``` - -The script creates a timestamped DB backup before making changes and prompts for old + new keys (input is not echoed). +

Reverse Proxy

@@ -405,62 +362,18 @@ Caddy handles TLS and WebSockets automatically. ## Environment variables -
-Full reference +Every variable, its default and what it does: see +[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables). -
+## Star History -| Variable | Description | Default | -|----------|-------------|---------| -| **Core** | | | -| `PORT` | Server port | `3000` | -| `NODE_ENV` | Environment (`production` / `development`) | `production` | -| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto | -| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` | -| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` | -| `TREK_WIKI_DIR` | Where the in-app Help pages (`/help`) read their content from. TREK ships its wiki and serves it from disk, so Help always matches the version you are running — you should not need to set this. Point it at your own directory to serve custom docs. If the path does not exist, Help falls back to fetching the public GitHub wiki (needs outbound network, and tracks the latest release). | bundled `wiki/` | -| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` | -| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin | -| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` | -| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` | -| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto | -| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` | -| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` | -| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` | -| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` | -| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — | -| **OIDC / SSO** | | | -| `OIDC_ISSUER` | OpenID Connect provider URL | — | -| `OIDC_CLIENT_ID` | OIDC client ID | — | -| `OIDC_CLIENT_SECRET` | OIDC client secret | — | -| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` | -| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` | -| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — | -| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — | -| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` | -| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — | -| **Initial setup** | | | -| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` | -| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random | -| **Other** | | | -| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` | -| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — | -| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` | -| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user. At the cap, the least-recently-active session is closed to make room | `20` | - -
- -
- -## Data & Backups - -- **Database** — SQLite, stored in `./data/travel.db` -- **Uploads** — stored in `./uploads/` -- **Logs** — `./data/logs/trek.log` (auto-rotated) -- **Backups** — create and restore via Admin Panel -- **Auto-Backups** — configurable schedule and retention in Admin Panel - -
+ + + + + Star History Chart + + ## Data sources @@ -469,7 +382,8 @@ The Atlas map's country and sub-national (province/county) boundaries come from [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md) for full third-party attributions. +
+ ## License TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence. - diff --git a/build-from-sources b/build-from-sources deleted file mode 100755 index 44baf80c5..000000000 --- a/build-from-sources +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" -CLIENT_DIR="$REPO_ROOT/client" -SERVER_DIR="$REPO_ROOT/server" -PUBLIC_DIR="$REPO_ROOT/server/public" - -echo "==> Installing client dependencies" -cd "$CLIENT_DIR" -npm ci - -echo "==> Building client" -npm run build - -echo "==> Installing server dependencies" -cd "$SERVER_DIR" -npm ci - -echo "==> Populating server/public" -find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete -cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/" -cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts" - -echo "==> Done — server/public is ready" diff --git a/client/.gitignore b/client/.gitignore index 11215320b..2530b230e 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -3,3 +3,6 @@ e2e/.tmp/ test-results/ playwright-report/ playwright/.cache/ + +# vite-plugin-pwa dev output (devOptions.enabled) +dev-dist/ diff --git a/client/CLAUDE.md b/client/CLAUDE.md new file mode 100644 index 000000000..57337de94 --- /dev/null +++ b/client/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only. + +## Commands (run from `client/`) + +```bash +npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001 +npm run build # prebuild generates PWA icons, then vite build +npm run typecheck # tsc --noEmit +npm run lint # eslint . +npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs) +npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx}) +npm run test:unit # tests/unit +npm run test:integration # tests/integration + co-located src tests +npm run test:coverage # v8 coverage +npm run e2e # Playwright +npm run e2e:report # open last Playwright report +npm run shots # Playwright screenshots project (shots:promote to accept) +npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate) +``` + +Single test: + +```bash +npx vitest run src/store/slices/budgetSlice.test.ts +npx vitest run -t "optimistically adds the place" +npx vitest watch src/pages/DashboardPage.test.tsx +``` + +The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both. + +## Page pattern (enforced — `lint:pages` will fail otherwise) + +Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use()` hook** under `src/pages//`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives. + +## Data flow — offline-first + +The layering is **store → repo → api | Dexie**, and writes go through a mutation queue: + +- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state. +- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly. +- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection. +- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access. +- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles. + +## Direction (from the 2026 audit) + +The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts: + +- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/.ts`, not the `client.ts` god module. +- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly. +- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`). +- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation). +- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug. +- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss. +- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects. +- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code. +- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces. +- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter. +- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate. +- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `` styling. + +## Other big-picture pieces + +- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure. +- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`. +- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens//` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated. +- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill. +- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build. +- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / ``; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`. + +## Tests + +vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`). diff --git a/client/e2e/auth.setup.ts b/client/e2e/auth.setup.ts index 46a342292..bd97a4444 100644 --- a/client/e2e/auth.setup.ts +++ b/client/e2e/auth.setup.ts @@ -1,4 +1,5 @@ import { test as setup, expect } from '@playwright/test' +import { dismissSystemNotices } from './helpers' // Relative to the config dir (client/), matching `storageState` in // playwright.config.ts. Playwright runs from the client workspace root. @@ -27,16 +28,12 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p await page.waitForURL('**/dashboard', { timeout: 30_000 }) - // Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders - // asynchronously (after the notices fetch), so wait for it before clicking. - // Dismissal is recorded server-side against this user, so clearing it here - // keeps it cleared for every authenticated flow in the run (shared test DB). - const ok = page.getByRole('button', { name: 'OK', exact: true }) - await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {}) - for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) { - await ok.click() - await page.waitForTimeout(400) - } + // Dismiss the first-run system-notice modal(s) — currently the thank-you / + // support modal, which has NO "OK" button (only CTAs + the X). The shared + // helper handles both notice shapes; dismissal is recorded server-side + // against this user, so clearing it here keeps it cleared for every + // authenticated flow in the run (shared test DB). + await dismissSystemNotices(page, 10_000) await page.context().storageState({ path: stateFile }) }) diff --git a/client/e2e/helpers.ts b/client/e2e/helpers.ts index c91d48152..fa6d43d3d 100644 --- a/client/e2e/helpers.ts +++ b/client/e2e/helpers.ts @@ -1,22 +1,43 @@ import type { Page } from '@playwright/test' /** - * Dismiss the release-notice modal (SystemNoticeHost), which greets a freshly seeded - * user on first load and covers the dashboard — its backdrop swallows clicks aimed at - * anything underneath, `.add-trip-card` included. + * Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly + * seeded user on first load and cover the dashboard — the backdrop swallows + * clicks aimed at anything underneath, `.add-trip-card` included. * - * The X only appears on the notice's last page, so page through first. Dismissal is - * persisted server-side per user, but each spec gets a fresh DB, so every spec that - * touches the dashboard has to clear it. + * The host renders asynchronously (after the notices fetch), so wait for the + * notice dialog before deciding there is nothing to clear. Every lookup is + * scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons + * (carousel arrows) and satisfy the wait before the modal even mounts. + * + * A notice closes one of two ways depending on its shape: + * - CTA-bearing notices (e.g. the thank-you/support modal) only offer the + * X button (`aria-label="Dismiss"`), shown on the last page. + * - CTA-less notices show an "OK" button that pages forward and dismisses on + * the last page. + * Multi-page notices are paged through via the pager's Next button first. + * Dismissal is persisted server-side per user, so clearing once keeps it + * cleared for every later spec in the run (shared test DB). */ -export async function dismissSystemNotices(page: Page): Promise { - const next = page.getByRole('button', { name: /next/i }) - for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) { - if (!(await next.isEnabled())) break - await next.click() +export async function dismissSystemNotices(page: Page, appearTimeoutMs = 3_000): Promise { + const dialog = page.getByRole('dialog').first() + await dialog.waitFor({ state: 'visible', timeout: appearTimeoutMs }).catch(() => {}) + + // Clear up to a handful of queued notices. + for (let notice = 0; notice < 4 && (await dialog.isVisible().catch(() => false)); notice++) { + const next = dialog.getByRole('button', { name: /next/i }) + for (let i = 0; i < 8 && (await next.isVisible().catch(() => false)); i++) { + if (!(await next.isEnabled().catch(() => false))) break + await next.click() + } + const dismiss = dialog.getByRole('button', { name: 'Dismiss', exact: true }) + const ok = dialog.getByRole('button', { name: 'OK', exact: true }) + if (await dismiss.isVisible().catch(() => false)) await dismiss.click() + else if (await ok.isVisible().catch(() => false)) await ok.click() + else break + // Exit animation + the next queued notice mounting. + await page.waitForTimeout(400) } - const dismiss = page.getByRole('button', { name: 'Dismiss' }) - if (await dismiss.isVisible().catch(() => false)) await dismiss.click() - await dismiss.waitFor({ state: 'detached' }).catch(() => {}) + await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {}) } diff --git a/client/e2e/mobile-day-swipe-2051.spec.ts b/client/e2e/mobile-day-swipe-2051.spec.ts new file mode 100644 index 000000000..9d3999c4b --- /dev/null +++ b/client/e2e/mobile-day-swipe-2051.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, devices } from '@playwright/test' +import type { Page } from '@playwright/test' +import { dismissSystemNotices } from './helpers' + +// Phone coverage for #2051: the day details area steps days under a horizontal +// swipe, so the trip is navigable one-handed instead of only from the chip rail +// pinned to the top of the screen. +// +// jsdom can drive the handlers but not the browser around them: whether the +// gesture survives real touch dispatch, whether the browser hands the vertical +// axis to its own scroller first, and whether a swipe at the first day stays put +// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the +// events are the ones a finger produces — not synthesised TouchEvent objects. +// +// Chromium, because CDP is where real touch injection lives. The file-level +// device sets a phone viewport with a coarse pointer, which is what puts the +// mobile shell on screen at all. +test.use({ ...devices['Pixel 7'] }) + +/** One finger, dispatched through the browser's own input pipeline. */ +async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) { + const cdp = await page.context().newCDPSession(page) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] }) + for (let i = 1; i <= steps; i++) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ + x: from.x + ((to.x - from.x) * i) / steps, + y: from.y + ((to.y - from.y) * i) / steps, + }], + }) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await cdp.detach() + // The out leg is 150ms and the in leg 220ms. + await page.waitForTimeout(500) +} + +const activeChip = (page: Page) => page.locator('button[aria-current="true"]') + +test('#2051 phone: the day details area swipes between days', async ({ page }) => { + await page.goto('/dashboard') + await dismissSystemNotices(page) + + // Without a phone viewport and a coarse pointer the mobile shell never mounts, + // and everything below would be proving nothing. + const env = await page.evaluate(() => ({ + width: window.innerWidth, + coarse: window.matchMedia('(pointer: coarse)').matches, + })) + expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768) + expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true) + + const stamp = Date.now() + const created = await page.request.post('/api/trips', { + data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' }, + }) + expect(created.ok(), 'seed trip').toBeTruthy() + const tripId = (await created.json()).trip?.id ?? (await created.json()).id + + await page.goto(`/trips/${tripId}`) + await expect(activeChip(page)).toBeVisible({ timeout: 30_000 }) + + const chips = page.locator('button[aria-current], button[aria-current="true"]') + const rail = activeChip(page).locator('xpath=..') + expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5) + expect(await chips.count()).toBeGreaterThan(0) + + // The swipe zone: below the chip rail, above the dock, across the middle. + const mid = Math.round(env.width / 2) + const y = 460 + + const day1 = (await activeChip(page).textContent())!.trim() + + // 1. A swipe left steps forward. + await swipe(page, { x: mid + 90, y }, { x: mid - 90, y }) + const day2 = (await activeChip(page).textContent())!.trim() + expect(day2, 'swiping left moved to the next day').not.toBe(day1) + + // 2. A swipe right steps back to where it started. + await swipe(page, { x: mid - 90, y }, { x: mid + 90, y }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + + // 3. The first day has nowhere to go back to — it must stay put, and the + // browser must not have been handed a page-back either. + await swipe(page, { x: mid - 90, y }, { x: mid + 90, y }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + expect(page.url()).toContain(`/trips/${tripId}`) + + // 4. A vertical drag belongs to the card's own scroller, never to the day. + await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + + // 5. And the rail itself still works, so the gesture added a path rather than + // replacing one. + await rail.locator('button').nth(2).click() + await expect(activeChip(page)).not.toHaveText(day1) +}) diff --git a/client/e2e/urlbar-scroll-1809.spec.ts b/client/e2e/urlbar-scroll-1809.spec.ts new file mode 100644 index 000000000..917748f76 --- /dev/null +++ b/client/e2e/urlbar-scroll-1809.spec.ts @@ -0,0 +1,105 @@ +import { test, expect, devices } from '@playwright/test' +import { dismissSystemNotices } from './helpers' + +// Phone regression guard for #1809: the dashboard must scroll the DOCUMENT. +// +// iOS Safari minimises its address bar only in reaction to the root scroller +// moving. V4 blocked that twice over: html was globally overflow:hidden and the +// mobile shell added a scroll container of its own, so the gesture never +// reached the viewport and the bar kept ~1/6th of the screen forever. The bar +// itself is not observable from page context, but the invariant it hangs off is: +// document.scrollingElement has to move. +// +// Needs WebKit (`npx playwright install webkit`). playwright.config.ts has no +// webkit project, the engine comes from this file-level device (defaultBrowserType +// 'webkit'), same as e2e/ipad-scroll-1432.spec.ts. WebKit is the point here, not +// a nicety: viewport propagation and the toolbar behaviour are Safari's. +// +// The file sorts last in the e2e run, so the trips it seeds cannot show up in +// another spec's dashboard assertions. +test.use({ ...devices['iPhone 14'] }) + +function readScroll() { + const el = document.scrollingElement as HTMLElement + return { + top: el.scrollTop, + scrollHeight: el.scrollHeight, + viewport: window.innerHeight, + htmlOverflow: getComputedStyle(document.documentElement).overflowY, + } +} + +test('#1809 iPhone: flow screens scroll the document, full-screen ones do not', async ({ page }) => { + await page.goto('/dashboard') + await dismissSystemNotices(page) + + // The context has to be the one from the report: phone width, coarse pointer. + // If either is wrong, nothing below proves anything. + const env = await page.evaluate(() => ({ + width: window.innerWidth, + coarse: window.matchMedia('(pointer: coarse)').matches, + })) + expect(env.width, 'iPhone sits below the 768px phone breakpoint').toBeLessThan(768) + expect(env.coarse, 'iPhone reports a coarse primary pointer').toBe(true) + + // Enough trips for the dashboard to overflow a phone screen. + const stamp = Date.now() + for (let i = 1; i <= 20; i++) { + const res = await page.request.post('/api/trips', { + data: { title: `URL bar ${stamp} ${i}`, start_date: '2099-06-01', end_date: '2099-06-10' }, + }) + expect(res.ok(), `seed trip ${i}`).toBeTruthy() + } + await page.reload() + await dismissSystemNotices(page) + await expect(page.getByText(`URL bar ${stamp} 1`).first()).toBeVisible({ timeout: 20_000 }) + + // 1. The root scroller is unlocked and the page is longer than the viewport. + const before = await page.evaluate(readScroll) + expect(before.htmlOverflow, 'html must not lock the viewport scroller').not.toBe('hidden') + expect(before.scrollHeight, 'dashboard is longer than one viewport').toBeGreaterThan(before.viewport) + expect(before.top).toBe(0) + + // 2. Scrolling moves the document, the signal Safari retracts its bar for. + await page.evaluate(() => window.scrollBy(0, 400)) + const scrolled = await page.evaluate(readScroll) + expect(scrolled.top, 'the document itself scrolled').toBeGreaterThan(0) + + // 3. The brand tile scrolls the page back up (it used to walk up to the shell + // container, which no longer exists). + await page.getByRole('button', { name: 'TREK' }).click() + await expect.poll(() => page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(0) + + // 4. The shared body scroll lock: with the document as the scroller, an open + // sheet must freeze the page behind it and give the position back. + await page.evaluate(() => window.scrollBy(0, 300)) + const locked = await page.evaluate(readScroll) + expect(locked.top).toBeGreaterThan(0) + + await page.getByRole('button', { name: 'New Trip' }).click() + await expect(page.getByRole('dialog')).toBeVisible() + await page.evaluate(() => window.scrollBy(0, 300)) + expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top) + + await page.keyboard.press('Escape') + await expect(page.getByRole('dialog')).toHaveCount(0) + expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBe(locked.top) + await page.evaluate(() => window.scrollBy(0, 200)) + expect(await page.evaluate(() => document.scrollingElement!.scrollTop)).toBeGreaterThan(locked.top) + + // 5. Counter-probe: the map screens keep their own full-viewport layout. They + // deliberately do NOT scroll the document (and so keep the address bar), and + // the map must not collapse to zero height now that the shell has none. + await page.goto('/atlas') + await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 }) + const atlas = await page.evaluate(() => { + const map = document.querySelector('.leaflet-container') as HTMLElement + window.scrollBy(0, 400) + return { + top: document.scrollingElement!.scrollTop, + mapHeight: map.getBoundingClientRect().height, + } + }) + expect(atlas.top, 'the atlas does not move the document').toBe(0) + expect(atlas.mapHeight, 'the atlas map keeps a real height').toBeGreaterThan(200) +}) diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 6e48d7e4f..f007649a9 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -75,4 +75,25 @@ export default tseslint.config( 'preserve-caught-error': 'warn', }, }, + { + // react-dom/server was worth ~190 KB raw / 57 KB gzip in a chunk three lazy + // routes share — including the Leaflet renderer, which is the default — for + // output that is always a single . utils/iconMarkup.ts does that job + // without Fizz, and this keeps the import from creeping back: nothing else + // would notice, the build stays green and the app keeps working. + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/**/*.test.{ts,tsx}'], + rules: { + 'no-restricted-imports': ['error', { + paths: [{ + name: 'react-dom/server', + message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one .', + }], + }], + 'no-restricted-syntax': ['error', { + selector: "ImportExpression > Literal[value=/^react-dom\\u002F(server|static)/]", + message: 'Use renderIconMarkup from utils/iconMarkup — Fizz is ~190 KB for one .', + }], + }, + }, ); diff --git a/client/index.html b/client/index.html index 9578ebd20..d31606d03 100644 --- a/client/index.html +++ b/client/index.html @@ -19,10 +19,7 @@ - - - - +
diff --git a/client/package.json b/client/package.json index d2684a41f..7c9e00253 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "dev": "vite", "prebuild": "node scripts/generate-icons.mjs", "build": "vite build", + "build:analyze": "vite build --mode analyze", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", @@ -17,6 +18,7 @@ "lint": "eslint .", "lint:check": "eslint .", "lint:pages": "node scripts/check-page-pattern.mjs", + "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", "e2e": "playwright test", @@ -27,9 +29,14 @@ "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" }, "dependencies": { + "@fontsource/bebas-neue": "^5.3.0", + "@fontsource/eb-garamond": "^5.3.0", "@fontsource/geist-sans": "^5.2.5", + "@fontsource/inter": "^5.3.0", + "@fontsource/lora": "^5.3.0", + "@fontsource/museomoderno": "^5.3.0", + "@fontsource/playfair-display": "^5.3.0", "@fontsource/poppins": "^5.2.7", - "@react-pdf/renderer": "^4.5.1", "@simplewebauthn/browser": "^13.1.2", "@trek/shared": "*", "axios": "^1.6.7", @@ -49,15 +56,14 @@ "react-leaflet": "^5.0.0", "react-leaflet-cluster": "^4.1.3", "react-markdown": "^10.1.0", - "react-router-dom": "^6.22.2", - "react-window": "^2.2.7", + "react-router": "^7.18.2", "rehype-sanitize": "^6.0.0", "remark-breaks": "^4.0.0", "remark-gfm": "^4.0.1", "topojson-client": "^3.1.0", "tz-lookup": "^6.1.25", "zod": "^4.3.6", - "zustand": "^4.5.2" + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -70,7 +76,6 @@ "@types/node": "^25.9.3", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", - "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^6.0.2", "@vitest/coverage-v8": "^4.1.9", "autoprefixer": "^10.4.18", @@ -86,7 +91,8 @@ "prettier": "^3.8.3", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.8.0", - "sharp": "^0.33.0", + "rollup-plugin-visualizer": "^7.0.1", + "sharp": "^0.35.0", "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.2", diff --git a/client/scripts/build-country-shapes.mjs b/client/scripts/build-country-shapes.mjs new file mode 100644 index 000000000..7cbbae3e6 --- /dev/null +++ b/client/scripts/build-country-shapes.mjs @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Country silhouettes for TREK Studio, built from the boundaries the server + * already bundles for Atlas. + * + * node scripts/build-country-shapes.mjs + * + * Writes `src/components/Studio/countryShapes.ts`. Run it when + * `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so + * a normal build never runs this. + * + * ── Why a second, tiny copy of the boundaries ───────────────────────────── + * + * Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy + * map you can zoom into. A book page draws a country at 20mm across, where the + * ten thousand points describing a fjord land inside one printer dot. Shipping + * the map data to draw a stamp would cost more than the entire Studio bundle. + * + * So: project, drop the islands nobody can see at that size, simplify hard, and + * normalise each country into its own 0..100 box. What comes out is a few + * hundred bytes per country and prints identically, because at 20mm it *is* + * identical. + * + * ── The three things that are easy to get wrong ─────────────────────────── + * + * 1. **Project before simplifying.** Simplifying in degrees and projecting + * afterwards removes the wrong points: a degree of longitude is 111km at the + * equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast + * while leaving Kenya's untouched. + * 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at + * +179 and -179. Taken literally their bounding box is the whole world and + * the silhouette is a horizontal smear. Rings that span more than half the + * globe get their negative longitudes lifted by 360 so the country is + * contiguous again. + * 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece + * into static. Rings below a fraction of the largest one are dropped — the + * mainland is what makes a country recognisable at a glance. + */ + +import fs from 'node:fs' +import path from 'node:path' +import url from 'node:url' +import zlib from 'node:zlib' + +const HERE = path.dirname(url.fileURLToPath(import.meta.url)) +const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz') +const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts') + +/** Rings smaller than this share of the biggest one are not drawn. */ +const ISLAND_CUTOFF = 0.012 +/** Never draw more than this many rings for one country. */ +const MAX_RINGS = 14 +/** Point budget per country, across every ring it keeps. */ +const MAX_POINTS = 190 + +/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */ +function project([lng, lat]) { + const clamped = Math.max(-85.05, Math.min(85.05, lat)) + const rad = (clamped * Math.PI) / 180 + return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI] +} + +function ringArea(ring) { + let a = 0 + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1] + } + return Math.abs(a / 2) +} + +/** Perpendicular distance from p to the segment ab, squared. */ +function segDistSq(p, a, b) { + let x = a[0], y = a[1] + let dx = b[0] - x, dy = b[1] - y + if (dx !== 0 || dy !== 0) { + const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy) + if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t } + } + dx = p[0] - x + dy = p[1] - y + return dx * dx + dy * dy +} + +/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */ +function simplify(points, tolerance) { + if (points.length < 3) return points + const tolSq = tolerance * tolerance + const keep = new Uint8Array(points.length) + keep[0] = 1 + keep[points.length - 1] = 1 + const stack = [[0, points.length - 1]] + + while (stack.length) { + const [first, last] = stack.pop() + let maxSq = 0 + let index = 0 + for (let i = first + 1; i < last; i++) { + const d = segDistSq(points[i], points[first], points[last]) + if (d > maxSq) { maxSq = d; index = i } + } + if (maxSq > tolSq) { + keep[index] = 1 + stack.push([first, index], [index, last]) + } + } + return points.filter((_, i) => keep[i]) +} + +/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */ +function outerRings(geometry) { + if (!geometry) return [] + if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1) + if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0]) + return [] +} + +/** + * Lift a ring that straddles the antimeridian into a continuous longitude + * range. A ring whose points span more than half the globe is, in practice, + * always a wrap rather than a genuinely hemisphere-wide landmass. + */ +function unwrap(ring) { + let min = Infinity + let max = -Infinity + for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng } + if (max - min <= 180) return ring + return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat]) +} + +function build(feature) { + const rings = outerRings(feature.geometry) + .map(unwrap) + .map(ring => ring.map(project)) + .map(ring => ({ ring, area: ringArea(ring) })) + .sort((a, b) => b.area - a.area) + + if (!rings.length) return null + + const biggest = rings[0].area + const kept = rings + .filter(r => r.area >= biggest * ISLAND_CUTOFF) + .slice(0, MAX_RINGS) + .map(r => r.ring) + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity + for (const ring of kept) { + for (const [x, y] of ring) { + if (x < minX) minX = x + if (x > maxX) maxX = x + if (y < minY) minY = y + if (y > maxY) maxY = y + } + } + const spanX = maxX - minX + const spanY = maxY - minY + if (!(spanX > 0) || !(spanY > 0)) return null + + // The longer side becomes 100 and the shorter one keeps its proportion, so a + // country never has to be squashed to fit the box it is drawn in. + const scale = 100 / Math.max(spanX, spanY) + const w = spanX * scale + const h = spanY * scale + const normalised = kept.map(ring => + ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale])) + + // Tolerance climbs until the whole country fits its point budget. Starting + // from the diagonal keeps the loop short for a country of any size. + let tolerance = 0.14 + let simplified = normalised.map(r => simplify(r, tolerance)) + let guard = 0 + while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) { + tolerance *= 1.35 + simplified = normalised.map(r => simplify(r, tolerance)) + } + + const d = simplified + .filter(ring => ring.length >= 3) + .map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`) + .join('') + + // The projected bounds, kept so the same path can be replayed in world + // coordinates. A country drawn on its own wants its own box; the same country + // on a route map has to sit where it actually is, next to its neighbours. One + // normalised path plus these four numbers serves both, where storing two sets + // of coordinates would be twice the bytes and one more thing to disagree. + return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null +} + +const round = n => Math.round(n * 10) / 10 + +const raw = zlib.gunzipSync(fs.readFileSync(SRC)) +const collection = JSON.parse(raw.toString('utf8')) + +const shapes = new Map() +for (const feature of collection.features) { + const code = String(feature.properties?.ISO_A2 || '').toUpperCase() + if (code.length !== 2 || code === '-9') continue + const built = build(feature) + if (!built) continue + // A code appearing twice means two features for one country; keep the one + // with more outline, which is the mainland rather than a dependency. + const prev = shapes.get(code) + if (!prev || built.d.length > prev.d.length) shapes.set(code, built) +} + +const codes = [...shapes.keys()].sort() +const body = codes + .map(code => { + const s = shapes.get(code) + return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },` + }) + .join('\n') + +const file = `/** + * GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`. + * + * Country silhouettes, simplified from the admin-0 boundaries the server + * bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator, + * normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's + * own proportions inside that box so it can be drawn without being squashed. + * + * ${codes.length} countries, built for print at postage-stamp size — islands + * below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is + * simplified to at most ${MAX_POINTS} points. + */ + +export interface CountryShape { + /** Path on a 0..100 box, outer rings only. */ + d: string + /** The shape's own width and height inside that box. */ + w: number + h: number + /** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */ + b: [number, number, number, number] +} + +/** + * Web Mercator, matching the projection the paths were built with. Longitude + * passes through as degrees; latitude is flipped so y grows downwards like + * SVG's does. + */ +export function projectMercator(lng: number, lat: number): { x: number; y: number } { + const clamped = Math.max(-85.05, Math.min(85.05, lat)) + const rad = (clamped * Math.PI) / 180 + return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI } +} + +/** + * The same outline, placed in projected world coordinates. + * + * The stored path is normalised into its own box; multiplying by the box's span + * and shifting by its origin puts it back where the country actually is, which + * is what a map with more than one country on it needs. + */ +export function countryWorldPath(shape: CountryShape): string { + const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100 + let axis = 0 + return shape.d.replace(/-?\\d*\\.?\\d+/g, m => { + const isX = axis++ % 2 === 0 + const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1]) + return String(Math.round(v * 1000) / 1000) + }) +} + +export const COUNTRY_SHAPES: Record = { +${body} +} + +/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */ +export function countryShape(code: string | null | undefined): CountryShape | null { + if (!code) return null + return COUNTRY_SHAPES[code.toUpperCase()] ?? null +} +` + +fs.writeFileSync(OUT, file, 'utf8') +const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10 +console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`) diff --git a/client/scripts/check-gl-split.mjs b/client/scripts/check-gl-split.mjs new file mode 100644 index 000000000..31fa69862 --- /dev/null +++ b/client/scripts/check-gl-split.mjs @@ -0,0 +1,47 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** + * Fails when a built chunk contains both WebGL map engines. + * + * mapbox-gl and maplibre-gl used to be static imports in the same three + * components, so rollup emitted a single 2.8 MB chunk carrying both. Every map + * user downloaded 1.8 MB of mapbox plus 1.1 MB of maplibre and ran one of them. + * A stray static import anywhere in the map tree brings that straight back, and + * nothing else would notice — the build stays green and the app still works. + * + * The markers are chosen to appear only inside the SDKs themselves, verified in + * node_modules. Do NOT test for the bare string "maplibre": the provider value + * 'maplibre-gl' and the setting key maplibre_style also appear in app code, so it + * matches the shared core chunk too. + */ +const DIR = 'dist/assets' +const MAPBOX = 'events.mapbox.com' // 1 hit in mapbox-gl, 0 in maplibre-gl +const MAPLIBRE = 'maplibregl-canvas' // 2 hits in maplibre-gl, 0 in mapbox-gl + +let mixed = 0 +let found = 0 + +for (const file of readdirSync(DIR).filter(f => f.endsWith('.js'))) { + const path = join(DIR, file) + const src = readFileSync(path, 'utf8') + const hasMapbox = src.includes(MAPBOX) + const hasMaplibre = src.includes(MAPLIBRE) + if (!hasMapbox && !hasMaplibre) continue + + found++ + const size = statSync(path).size + if (hasMapbox && hasMaplibre) { + console.error(`FAIL both GL engines in one chunk: ${file} (${size} B)`) + mixed++ + } else { + console.log(`${hasMapbox ? 'mapbox ' : 'maplibre'} ${file} ${size} B`) + } +} + +if (!found) { + console.error('FAIL no chunk contains either GL engine — did the build run?') + process.exit(1) +} + +process.exit(mixed ? 1 : 0) diff --git a/client/scripts/check-page-pattern.mjs b/client/scripts/check-page-pattern.mjs index 5d7d789c9..05acc51a9 100644 --- a/client/scripts/check-page-pattern.mjs +++ b/client/scripts/check-page-pattern.mjs @@ -2,9 +2,12 @@ // src/pages/PATTERN.md). A *Page.tsx default-export component should wire a // co-located use() hook into JSX — it must not own state/effects itself. // -// We scan only the default-export component body (from `export default function` -// up to the next top-level `function` declaration or EOF), so presentational -// sub-components and helper hooks living in the same file are not flagged. +// We scan the default-export component body (from `export default function` up +// to the next top-level `function` declaration or EOF), and the same span of a +// top-level `*PageDesktop`/`*PageMobile` — since ViewportRoute started picking +// the branch, the default export is a two-line shim and the real container is +// one of those two. Everything else at top level stays exempt: presentational +// sub-components and helper hooks live in these files on purpose. // Context hooks like useTranslation/useParams are fine; the smell is stateful // logic — useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef. import { readdirSync, readFileSync } from 'node:fs' @@ -13,23 +16,38 @@ import { fileURLToPath } from 'node:url' const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages') const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef'] -const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`) +// `[<(]` rather than `\(`: a generic call — `useRef<() => void>(null)` — is the +// same thing, and it slipped through for as long as the pattern only knew `(`. +const bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*[<(]`) + +// Declarations the widened scan caught that are not this change's to move. +// Listed by the name they declare rather than by line, so an entry dies with +// its declaration instead of drifting onto whatever follows it. The rest of +// each listed file is still checked. +const KNOWN_ESCAPES = { + 'TripPlannerPage.tsx': ['glMap', 'bookingExpense'], +} const violations = [] for (const file of readdirSync(pagesDir)) { if (!file.endsWith('Page.tsx') || file.endsWith('.test.tsx')) continue const src = readFileSync(join(pagesDir, file), 'utf8') const lines = src.split('\n') - const start = lines.findIndex(l => /export default function/.test(l)) - if (start === -1) continue - // The page body ends at the next top-level declaration (a `function` at - // column 0) — everything after that is a sub-component or helper. - let end = lines.length - for (let i = start + 1; i < lines.length; i++) { - if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break } - } - for (let i = start; i < end; i++) { - if (bannedRe.test(lines[i])) { + const allowed = KNOWN_ESCAPES[file] ?? [] + const starts = [] + lines.forEach((line, i) => { + if (/export default function/.test(line) || /^function \w+Page(Desktop|Mobile)\b/.test(line)) starts.push(i) + }) + for (const start of starts) { + // The page body ends at the next top-level declaration (a `function` at + // column 0) — everything after that is a sub-component or helper. + let end = lines.length + for (let i = start + 1; i < lines.length; i++) { + if (/^(function |const [A-Z]\w* = )/.test(lines[i])) { end = i; break } + } + for (let i = start; i < end; i++) { + if (!bannedRe.test(lines[i])) continue + if (allowed.some(name => new RegExp(`\\b${name}\\b`).test(lines[i]))) continue violations.push(`${file}:${i + 1} ${lines[i].trim()}`) } } diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 9062f7938..ff9c2fe90 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -1,13 +1,15 @@ import React from 'react' -import { render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter } from 'react-router-dom' +import { render, screen, waitFor, act } from '@testing-library/react' +import { MemoryRouter, useLocation } from 'react-router' import { describe, it, expect, beforeEach, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '../tests/helpers/msw/server' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' import { resetAllStores } from '../tests/helpers/store' -import { buildUser, buildSettings } from '../tests/helpers/factories' +import { buildUser, buildSettings, buildTrip } from '../tests/helpers/factories' +import { offlineDb } from './db/offlineDb' +import { SETTINGS_WAIT_MS } from './utils/startDestination' import App from './App' // ── Mock page components ─────────────────────────────────────────────────────── @@ -29,10 +31,19 @@ vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ // ── Helpers ──────────────────────────────────────────────────────────────────── +/** Reports the router's current location, so a test can assert where App sent it. */ +function LocationProbe() { + const loc = useLocation() + return {loc.pathname + loc.search} +} + +const currentPath = () => screen.getByTestId('loc').textContent + function renderApp(initialPath = '/') { return render( + ) } @@ -81,6 +92,105 @@ describe('RootRedirect', () => { }) }) +// ── RootRedirect — startup destination ──────────────────────────────────────── + +describe('RootRedirect — startup destination', () => { + /** Serves GET /api/trips/active and reports whether it was asked at all. */ + function stubActiveTrip(trip: { id: number; title: string } | null) { + const calls: string[] = [] + server.use( + http.get('/api/trips/active', ({ request }) => { + calls.push(request.url) + return HttpResponse.json({ trip }) + }), + ) + return calls + } + + it('FE-COMP-APP-026: opens the active trip on the chosen tab', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), + }) + stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + }) + + it('FE-COMP-APP-027: falls back to the dashboard when the user has no trip', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), + }) + stubActiveTrip(null) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + }) + + it('FE-COMP-APP-028: falls back to the dashboard when the lookup fails', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip' }), + }) + server.use(http.get('/api/trips/active', () => HttpResponse.error())) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + }) + + it('FE-COMP-APP-028b: opens the cached active trip when the launch is offline', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip' }), + }) + const today = new Date() + const iso = (d: Date) => d.toISOString().slice(0, 10) + await offlineDb.trips.put(buildTrip({ id: 42, title: 'Japan', start_date: iso(today), end_date: iso(today) })) + const onLine = Object.getOwnPropertyDescriptor(Navigator.prototype, 'onLine') + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }) + + // navigator and the Dexie table are shared with every other test in the + // file, so a failed assertion must not leave the rest of them offline. + try { + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + } finally { + if (onLine) Object.defineProperty(Navigator.prototype, 'onLine', onLine) + delete (navigator as unknown as { onLine?: boolean }).onLine + await offlineDb.trips.clear() + } + }) + + // The whole point of the localStorage mirror: the default launch must not pay + // for a lookup it doesn't need. + it('FE-COMP-APP-029: never asks for the active trip when starting on the dashboard', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: true, settings: buildSettings({ start_page: 'dashboard' }) }) + const calls = stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + expect(calls).toHaveLength(0) + }) + + it('FE-COMP-APP-030: reads the preference from localStorage before settings have loaded', async () => { + localStorage.setItem('trek_start_page', 'active_trip') + localStorage.setItem('trek_start_trip_tab', 'finanzplan') + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: false }) + stubActiveTrip({ id: 42, title: 'Japan' }) + + renderApp('/') + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + }) +}) + // ── ProtectedRoute — unauthenticated ────────────────────────────────────────── describe('ProtectedRoute — unauthenticated', () => { @@ -90,6 +200,22 @@ describe('ProtectedRoute — unauthenticated', () => { await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) }) + // A session that ran out should return you to the page you were on. Pressing + // "log out" should not: clearing isAuthenticated re-renders this route for the + // page still on screen, and the ?redirect= it stamped there beat the user's + // startup destination on every login after the first. + it('FE-COMP-APP-034: keeps the return ticket when the session merely ended', async () => { + seedAuth({ isAuthenticated: false, loggingOut: false }) + renderApp('/trips/1/files') + await waitFor(() => expect(currentPath()).toBe('/login?redirect=%2Ftrips%2F1%2Ffiles')) + }) + + it('FE-COMP-APP-035: drops it on a deliberate sign-out, so the startup destination decides', async () => { + seedAuth({ isAuthenticated: false, loggingOut: true }) + renderApp('/dashboard') + await waitFor(() => expect(currentPath()).toBe('/login')) + }) + it('FE-COMP-APP-005: /trips/42 redirects to /login when not authenticated', async () => { seedAuth({ isAuthenticated: false }) renderApp('/trips/42') @@ -169,6 +295,8 @@ describe('ProtectedRoute — admin role check', () => { // ── Public routes ────────────────────────────────────────────────────────────── describe('Public routes', () => { + // Synchronous on purpose: LoginPage is the one page still statically imported, + // so this also holds the line against someone making it lazy later. it('FE-COMP-APP-012: /login is accessible without authentication', async () => { seedAuth({ isAuthenticated: false }) renderApp('/login') @@ -178,7 +306,7 @@ describe('Public routes', () => { it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => { seedAuth({ isAuthenticated: false }) renderApp('/shared/sometoken') - expect(screen.getByText('SharedTrip')).toBeInTheDocument() + expect(await screen.findByText('SharedTrip')).toBeInTheDocument() }) it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => { @@ -188,6 +316,57 @@ describe('Public routes', () => { }) }) +// ── PublicRoute — redirect already-authenticated visitors ───────────────────── + +describe('PublicRoute — already authenticated', () => { + /** + * The bounce goes to START_DESTINATION_ROUTE, so RootRedirect resolves where + * it actually lands — which needs settings, exactly like the cases above. + */ + function seedAuthedOnDashboard() { + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'dashboard' }), + }) + } + + it('FE-COMP-APP-012a: /login sends an authenticated visitor to the start destination', async () => { + seedAuthedOnDashboard() + renderApp('/login') + await waitFor(() => expect(currentPath()).toBe('/dashboard')) + expect(screen.queryByText('Login')).not.toBeInTheDocument() + }) + + it('FE-COMP-APP-012b: /register does the same', async () => { + seedAuthedOnDashboard() + renderApp('/register') + await waitFor(() => expect(currentPath()).toBe('/dashboard')) + expect(screen.queryByText('Login')).not.toBeInTheDocument() + }) + + it('FE-COMP-APP-012c: /login with a ?redirect= target is left to useLogin (OAuth consent handoff), not bounced', async () => { + // The consent page parks its URL in ?redirect= when it needs a login; that + // flow must reach the form even for an authenticated visitor, so the guard + // stays out of the way whenever a redirect target is present. + seedAuthedOnDashboard() + const target = '/login?redirect=' + encodeURIComponent('/oauth/consent?client_id=x') + renderApp(target) + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) + expect(currentPath()).toBe(target) + }) + + it('FE-COMP-APP-012d: /register?invite= reaches the form so the token can be validated', async () => { + // An invite link is the one way someone joins a trip they were invited to + // from an account they are already signed into; bouncing it would swallow + // the token silently. + seedAuthedOnDashboard() + renderApp('/register?invite=abc123') + await waitFor(() => expect(screen.getByText('Login')).toBeInTheDocument()) + expect(currentPath()).toBe('/register?invite=abc123') + }) +}) + // ── App — on-mount effects ───────────────────────────────────────────────────── describe('App — on-mount effects', () => { @@ -233,6 +412,30 @@ describe('App — on-mount effects', () => { await waitFor(() => expect(setDemoMode).toHaveBeenCalledWith(true)) }) + it('FE-COMP-APP-018b: setManaged mirrors the config flag, and defaults to false when it is absent', async () => { + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({ managed: true })) + ) + const setManaged = vi.fn() + useAuthStore.setState({ + isLoading: false, + isAuthenticated: false, + loadUser: vi.fn().mockResolvedValue(undefined), + setManaged, + }) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(true)) + + // An older server that does not send the field must not leave the flag + // stuck on from a previous install in the same browser profile. + setManaged.mockClear() + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({})) + ) + renderApp('/') + await waitFor(() => expect(setManaged).toHaveBeenCalledWith(false)) + }) + it('FE-COMP-APP-019: loadSettings is called once the user is authenticated', async () => { const loadSettings = vi.fn().mockResolvedValue(undefined) seedAuth({ isAuthenticated: true, user: buildUser() }) @@ -320,3 +523,56 @@ describe('Version cache-busting', () => { await waitFor(() => expect(reload).toHaveBeenCalled()) }) }) + +// Regression: a device that has never mirrored the preference must not treat +// "nothing mirrored" as "user wants the dashboard". +describe('RootRedirect — preference not mirrored on this device', () => { + it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => { + seedAuth({ isAuthenticated: true, user: buildUser() }) + // Cold start: settings are still in flight when RootRedirect first runs. + useSettingsStore.setState({ isLoaded: false }) + server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } }))) + + renderApp('/') + + // ...and land a moment later, exactly as loadSettings() does. + await act(async () => { + useSettingsStore.setState({ + isLoaded: true, + settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }), + }) + }) + + await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument()) + }) + + // Waiting for someone else's effect to fetch the settings made the decision + // ride on where that request landed in the queue — behind ~380 others on a + // cold start, which is how the redirect silently lost the race and fell + // through to the dashboard. It now asks for them itself. + it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => { + const loadSettings = vi.fn().mockResolvedValue(undefined) + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: false, loadSettings }) + + renderApp('/') + + await waitFor(() => expect(loadSettings).toHaveBeenCalled()) + }) + + // loadSettings leaves isLoaded false on a failed request so it can retry, so + // the wait above needs a floor or a launch with no backend never resolves. + it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + seedAuth({ isAuthenticated: true, user: buildUser() }) + useSettingsStore.setState({ isLoaded: false }) + + renderApp('/') + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument() + + await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) }) + + await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument()) + vi.useRealTimers() + }) +}) diff --git a/client/src/App.tsx b/client/src/App.tsx index 75c323e90..523e6150b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,36 +1,27 @@ -import React, { useEffect, ReactNode } from 'react' -import { Routes, Route, Navigate, useLocation } from 'react-router-dom' +import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react' +import { Routes, Route, Navigate, useLocation } from 'react-router' import { useAuthStore } from './store/authStore' import { useSettingsStore } from './store/settingsStore' import { applyAppearance } from './theme/applyAppearance' import { useAddonStore } from './store/addonStore' import { usePluginStore } from './store/pluginStore' -import PluginPage from './pages/PluginPage' +// The one page that stays in the entry chunk. Anyone logged out lands here, and +// every other route redirects here first — a chunk round trip in front of the login +// form would slow down the single screen that has to be there immediately. import LoginPage from './pages/LoginPage' -import ForgotPasswordPage from './pages/ForgotPasswordPage' -import ResetPasswordPage from './pages/ResetPasswordPage' -import DashboardPage from './pages/DashboardPage' -import TripPlannerPage from './pages/TripPlannerPage' -import FilesPage from './pages/FilesPage' -import AdminPage from './pages/AdminPage' -import SettingsPage from './pages/SettingsPage' -import VacayPage from './pages/VacayPage' -import HelpPage from './pages/HelpPage' -import AtlasPage from './pages/AtlasPage' -import JourneyPage from './pages/JourneyPage' -import JourneyDetailPage from './pages/JourneyDetailPage' -import CollectionsPage from './pages/CollectionsPage' -import JourneyPublicPage from './pages/JourneyPublicPage' -import SharedTripPage from './pages/SharedTripPage' -import JoinTripPage from './pages/JoinTripPage' -import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx' -import OAuthAuthorizePage from './pages/OAuthAuthorizePage' import { ToastContainer } from './components/shared/Toast' import SaveToCollectionModal from './components/Collections/SaveToCollectionModal' +import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet' import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget' -import BottomNav from './components/Layout/BottomNav' +import MobileShell from './mobile/MobileShell' +import MRouteFallback from './mobile/components/MRouteFallback' +import ErrorBoundary from './components/shared/ErrorBoundary' +import { lazyWithRetry } from './utils/lazyWithRetry' +import { useIsPhone } from './mobile/useIsPhone' import { TranslationProvider, useTranslation } from './i18n' import { authApi } from './api/client' +import { tripRepo } from './repo/tripRepo' +import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination' import { usePermissionsStore, PermissionLevel } from './store/permissionsStore' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' @@ -38,6 +29,50 @@ import OfflineBanner from './components/Layout/OfflineBanner' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' // Notice action registrations (side-effect imports): import './pages/Trips/noticeActions.js' +import { managedRoutes } from './managed' + +// Every page below loads on demand. The entry chunk used to carry all twenty of +// them eagerly, so opening /dashboard also paid for the planner, the journal, the +// atlas and the vacation planner. lazyWithRetry rather than lazy: a chunk that +// fails once gets a second, cache-busted attempt before the route boundary reaches +// for a reload. +const PluginPage = lazyWithRetry(() => import('./pages/PluginPage')) +const ForgotPasswordPage = lazyWithRetry(() => import('./pages/ForgotPasswordPage')) +const ResetPasswordPage = lazyWithRetry(() => import('./pages/ResetPasswordPage')) +const DashboardPage = lazyWithRetry(() => import('./pages/DashboardPage')) +const TripPlannerPage = lazyWithRetry(() => import('./pages/TripPlannerPage')) +const FilesPage = lazyWithRetry(() => import('./pages/FilesPage')) +const AdminPage = lazyWithRetry(() => import('./pages/AdminPage')) +const SettingsPage = lazyWithRetry(() => import('./pages/SettingsPage')) +const VacayPage = lazyWithRetry(() => import('./pages/VacayPage')) +const HelpPage = lazyWithRetry(() => import('./pages/HelpPage')) +const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage')) +const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage')) +const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage')) +const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage')) +const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage')) +const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage')) +const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage')) +const JoinTripPage = lazyWithRetry(() => import('./pages/JoinTripPage')) +const InAppNotificationsPage = lazyWithRetry(() => import('./pages/InAppNotificationsPage.tsx')) +const OAuthAuthorizePage = lazyWithRetry(() => import('./pages/OAuthAuthorizePage')) + +// The ten phone screens are chunks of their own, alongside the desktop pages +// rather than inside them. Each page used to import its M screen statically and +// decide while rendering, so the route chunk always carried both trees and the +// viewport only picked which half stayed dark. Here the branch decides the +// chunk: a phone never loads the desktop planner, a desktop never the mobile +// shell. +const MDashboardScreen = lazyWithRetry(() => import('./mobile/screens/dashboard/MDashboard')) +const MTripScreen = lazyWithRetry(() => import('./mobile/screens/trip/MTripShell')) +const MAdminScreen = lazyWithRetry(() => import('./mobile/screens/admin/MAdmin')) +const MSettingsScreen = lazyWithRetry(() => import('./mobile/screens/settings/MSettings')) +const MVacayScreen = lazyWithRetry(() => import('./mobile/screens/vacay/MVacay')) +const MAtlasScreen = lazyWithRetry(() => import('./mobile/screens/atlas/MAtlas')) +const MJourneyScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourney')) +const MJourneyDetailScreen = lazyWithRetry(() => import('./mobile/screens/journey/MJourneyDetail')) +const MCollectionsScreen = lazyWithRetry(() => import('./mobile/screens/collections/MCollections')) +const MNotificationsScreen = lazyWithRetry(() => import('./mobile/screens/notifications/MNotifications')) interface ProtectedRouteProps { children: ReactNode @@ -50,9 +85,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR const user = useAuthStore((s) => s.user) const isLoading = useAuthStore((s) => s.isLoading) const appRequireMfa = useAuthStore((s) => s.appRequireMfa) + const loggingOut = useAuthStore((s) => s.loggingOut) const addonStore = useAddonStore() const { t } = useTranslation() const location = useLocation() + const isPhone = useIsPhone() if (isLoading) { return ( @@ -66,6 +103,10 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR } if (!isAuthenticated) { + // A session that ended on its own should come back to where it left off; a + // deliberate sign-out is a fresh start and gets no return ticket, so the + // startup destination decides where the next login lands. + if (loggingOut) return const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) return } @@ -87,18 +128,132 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR return } + // Below the md breakpoint the new mobile shell owns chrome (tokens, dock, + // sheets, toasts); from 768px up the legacy wrapper stays untouched. The + // shell branches internally so pages keep their state when the viewport + // crosses the breakpoint. + // The boundary sits inside the shell so a broken page keeps the navigation the + // user needs to leave it — outside, the route would be a dead end, and the + // mobile --m-* tokens live on the shell's .m-root anyway. + // + // key, not resetKeys: ProtectedRoute is the same component at the same position + // for all 16 protected routes, so without it React could keep the failed + // instance across a navigation and the error would follow the user around. return ( -
-
{children}
- -
+ + + {children} + + ) } +/** + * The public routes render outside ProtectedRoute, so the route boundary above + * never sees them — including /login, where someone lands when everything else + * failed, and the two anonymous share pages. + */ +function PublicRoute({ children, redirectAuthed = false }: { children: React.ReactNode; redirectAuthed?: boolean }) { + const location = useLocation() + // redirectAuthed (only /login and /register) bounces a visitor who is already + // authenticated when they land here — manual URL, browser back button (#1810). + // Only on a bare URL, because every parameter this page takes is a flow that + // has to reach useLogin intact: ?redirect= carries the OAuth consent handoff + // (bouncing drops it, or loops if the server still reports login_required), + // ?invite= puts the form into register mode against a validated token, and + // ?oidc_code= / ?oidc_error= complete an external login. A visitor with any of + // those in the URL asked for this page on purpose. + // Capture the flag at mount so a fresh login is left alone: it flips + // isAuthenticated to true while the takeoff animation still plays here, before + // useLogin navigates away. + // The target is START_DESTINATION_ROUTE, not /dashboard, so the bounce honours + // the start-page preference the same way useLogin does. + const wasAuthenticated = useRef(useAuthStore.getState().isAuthenticated) + if (redirectAuthed && wasAuthenticated.current && !location.search) { + return + } + return ( + + {children} + + ) +} + +/** + * Picks the chunk, not just the branch. Both sides come through lazyWithRetry, + * so exactly one of them is fetched for a given viewport — which is the whole + * point: the ternary that used to sit in each page only ran once the browser + * had already paid for both trees. + */ +function ViewportRoute({ phone: Phone, desktop: Desktop }: { + phone: React.ComponentType + desktop: React.ComponentType +}): React.ReactElement { + const isPhone = useIsPhone() + return isPhone ? : +} + +/** + * The entry point every shortcut, bookmark and the installed PWA share + * (manifest start_url is '/'), which is why the startup destination is decided + * here rather than on /dashboard — landing on the dashboard directly has to keep + * working, or there would be no way back to it. + * + * Once this device has seen the preference it comes from the localStorage + * mirror, so the common launch waits for nothing. A device that has never seen + * it waits for the settings instead of assuming the default — the preference + * lives on the server, and guessing here would ignore it on every browser that + * didn't set it. Only 'active_trip' costs the one lookup that finds the trip, + * and any failure along the way lands on the dashboard. + */ function RootRedirect() { const { isAuthenticated, isLoading } = useAuthStore() + const settingsLoaded = useSettingsStore((s) => s.isLoaded) + const settings = useSettingsStore((s) => s.settings) + const loadSettings = useSettingsStore((s) => s.loadSettings) + const [target, setTarget] = useState(null) + // Bounds the wait below: loadSettings deliberately leaves isLoaded false on a + // failed request so it can retry, which would otherwise strand a launch that + // started offline on the spinner. + const [settingsGaveUp, setSettingsGaveUp] = useState(false) - if (isLoading) { + useEffect(() => { + if (settingsLoaded) return + const timer = setTimeout(() => setSettingsGaveUp(true), SETTINGS_WAIT_MS) + return () => clearTimeout(timer) + }, [settingsLoaded]) + + useEffect(() => { + if (isLoading || !isAuthenticated || target) return + const mirrored = readStartDestination() + if (!mirrored && !settingsLoaded && !settingsGaveUp) { + // Ask for the settings instead of waiting for whoever else might. The + // store de-dupes concurrent loads, so this is free when one is already in + // flight — and it stops the decision from riding on where that request + // happens to land in the queue behind everything else a launch fires off. + loadSettings() + return + } + // Loaded settings are the truth; the mirror is what we knew last time. + const { page, tab } = settingsLoaded + ? { page: settings.start_page ?? DEFAULT_START_PAGE, tab: settings.start_trip_tab ?? DEFAULT_START_TRIP_TAB } + : (mirrored ?? { page: DEFAULT_START_PAGE, tab: DEFAULT_START_TRIP_TAB }) + if (page !== 'active_trip') { setTarget('/dashboard'); return } + let cancelled = false + // Through the repo, not the api: offline this answers from Dexie instead of + // bouncing a cached trip to the dashboard. + tripRepo.active() + .then(({ trip }) => { if (!cancelled) setTarget(trip ? tripStartPath(trip.id, tab) : '/dashboard') }) + .catch(() => { if (!cancelled) setTarget('/dashboard') }) + return () => { cancelled = true } + }, [isLoading, isAuthenticated, settingsLoaded, settingsGaveUp, settings, target]) + + if (isLoading || (isAuthenticated && !target)) { return (
@@ -106,11 +261,29 @@ function RootRedirect() { ) } - return + return +} + +/** + * Shown while a route chunk is in flight. Same geometry as the auth spinner above, + * but on theme tokens — that one predates the styleguide and a new surface does not + * get to inherit its raw slate. + */ +function RouteFallback() { + // The fallback renders above MobileShell, so it has to pick its own palette — + // on a phone the desktop spinner on bg-surface would be a foreign white sheet + // in front of the mobile screen. + const isPhone = useIsPhone() + if (isPhone) return + return ( +
+
+
+ ) } export default function App() { - const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore() + const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -127,7 +300,8 @@ export default function App() { loadUser() } } - authApi.getAppConfig().then(async (config: { demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; permissions?: Record }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record }) => { + setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) @@ -139,6 +313,7 @@ export default function App() { if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled) if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled) if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled) + if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled) if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions) if (config?.version) { @@ -200,6 +375,7 @@ export default function App() { } }, [settings.dark_mode, settings.appearance, isSharedPage]) + const isPhone = useIsPhone() const isAuthPage = location.pathname.startsWith('/login') || location.pathname.startsWith('/register') || location.pathname.startsWith('/forgot-password') @@ -207,153 +383,173 @@ export default function App() { return ( - {!isAuthPage && } - - {!isAuthPage && } - {!isAuthPage && } - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} - } /> - - - - } - /> - {/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous - visitor is redirected to /login (never registration) and returns here. */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - + {!isAuthPage && } + + {!isAuthPage && } + {!isAuthPage && (isPhone ? : )} + + {/* One boundary for all route chunks, above so it stays mounted + across navigations. react-router runs location updates inside a transition, + so a mounted boundary keeps the current page on screen instead of flashing + a spinner on every jump — the spinner is for the first paint of a deep link. */} + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */} + } /> + + + + } + /> + {/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous + visitor is redirected to /login (never registration) and returns here. */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/* Empty in this repository, and read unconditionally so the public + build walks the same path as any other. See client/src/managed. */} + {managedRoutes.map(r => ( + {r.element}} + /> + ))} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + > + {/* Studio is nested so the journey stays mounted underneath it and + shows through the panel's margin — it is opened on top of the + journey, not navigated away to. */} + } /> + + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ) } diff --git a/client/src/App.viewport.test.tsx b/client/src/App.viewport.test.tsx new file mode 100644 index 000000000..13c37d5e5 --- /dev/null +++ b/client/src/App.viewport.test.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useAuthStore } from './store/authStore' +import { useAddonStore } from './store/addonStore' +import { resetAllStores } from '../tests/helpers/store' +import { buildUser } from '../tests/helpers/factories' +import App from './App' + +/** + * The viewport decides which chunk gets fetched, so the switch lives in App.tsx + * and no longer in the pages. This is that contract, for all ten routes in one + * place — it used to be five scattered spot checks, one per page test. + * + * A stub per screen keeps the test about one thing: below the breakpoint the M + * root hangs off the route, and the desktop page is never rendered at all. + */ + +const isPhone = vi.hoisted(() => ({ value: false })) +vi.mock('./mobile/useIsPhone', () => ({ useIsPhone: () => isPhone.value })) + +vi.mock('./mobile/screens/dashboard/MDashboard', () => ({ default: () =>
m-dashboard
})) +vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () =>
m-trip
})) +vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () =>
m-admin
})) +vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () =>
m-settings
})) +vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () =>
m-vacay
})) +vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () =>
m-atlas
})) +vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () =>
m-journey
})) +vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () =>
m-journey-detail
})) +vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () =>
m-collections
})) +vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () =>
m-notifications
})) + +vi.mock('./pages/DashboardPage', () => ({ default: () =>
d-dashboard
})) +vi.mock('./pages/TripPlannerPage', () => ({ default: () =>
d-trip
})) +vi.mock('./pages/AdminPage', () => ({ default: () =>
d-admin
})) +vi.mock('./pages/SettingsPage', () => ({ default: () =>
d-settings
})) +vi.mock('./pages/VacayPage', () => ({ default: () =>
d-vacay
})) +vi.mock('./pages/AtlasPage', () => ({ default: () =>
d-atlas
})) +vi.mock('./pages/JourneyPage', () => ({ default: () =>
d-journey
})) +vi.mock('./pages/JourneyDetailPage', () => ({ default: () =>
d-journey-detail
})) +vi.mock('./pages/CollectionsPage', () => ({ default: () =>
d-collections
})) +vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () =>
d-notifications
})) + +// The notification listener opens a WebSocket on mount. +vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ + useInAppNotificationListener: vi.fn(), +})) + +/** path, mobile marker, desktop marker */ +const ROUTES: [string, string, string][] = [ + ['/dashboard', 'm-dashboard', 'd-dashboard'], + ['/trips/1', 'm-trip', 'd-trip'], + ['/admin', 'm-admin', 'd-admin'], + ['/settings', 'm-settings', 'd-settings'], + ['/vacay', 'm-vacay', 'd-vacay'], + ['/atlas', 'm-atlas', 'd-atlas'], + ['/journey', 'm-journey', 'd-journey'], + ['/journey/1', 'm-journey-detail', 'd-journey-detail'], + ['/collections', 'm-collections', 'd-collections'], + ['/collections/1', 'm-collections', 'd-collections'], + ['/notifications', 'm-notifications', 'd-notifications'], +] + +function renderAt(path: string) { + return render( + + + + ) +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + isPhone.value = false + // An admin passes the role check on /admin; every other route ignores it. + useAuthStore.setState({ + isLoading: false, + isAuthenticated: true, + user: buildUser({ role: 'admin', mfa_enabled: true }), + appRequireMfa: false, + loadUser: vi.fn().mockResolvedValue(undefined), + }) + // /journey and /collections sit behind addons; without this they redirect to + // the dashboard and the route under test never renders. + useAddonStore.setState({ loaded: true, isEnabled: () => true }) +}) + +describe('App — viewport routing', () => { + it.each(ROUTES)( + 'FE-COMP-APPVP-001: %s renders the mobile screen below the breakpoint', + async (path, mobile, desktop) => { + isPhone.value = true + renderAt(path) + + expect(await screen.findByText(mobile)).toBeInTheDocument() + expect(screen.queryByText(desktop)).not.toBeInTheDocument() + } + ) + + it.each(ROUTES)( + 'FE-COMP-APPVP-002: %s renders the desktop page above the breakpoint', + async (path, mobile, desktop) => { + renderAt(path) + + expect(await screen.findByText(desktop)).toBeInTheDocument() + expect(screen.queryByText(mobile)).not.toBeInTheDocument() + } + ) +}) diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts new file mode 100644 index 000000000..e33de84f8 --- /dev/null +++ b/client/src/api/client.behavior.test.ts @@ -0,0 +1,511 @@ +// FE-APIWIRE-001 to FE-APIWIRE-036 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { weatherResultSchema } from '@trek/shared' + +// client.ts probes the health endpoint to tell an edge-proxy auth wall apart +// from a plain offline boot — the probe result decides whether it tears down +// the service worker, so the tests drive it directly. +const { probeNow } = vi.hoisted(() => ({ + probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'), +})) +vi.mock('../sync/connectivity', () => ({ probeNow })) + +const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client') + +interface FakeLocation { + href: string + origin: string + pathname: string + search: string + hash: string + reload: () => void +} + +let reload: ReturnType void>> + +function setLocation(pathname: string, search = '', hash = ''): FakeLocation { + reload = vi.fn<() => void>() + const loc: FakeLocation = { + href: `http://localhost:3000${pathname}${search}${hash}`, + origin: 'http://localhost:3000', + pathname, + search, + hash, + reload, + } + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc }) + return loc +} + +const realLocation = window.location + +/** Records the outgoing config and answers 200 without touching the network. */ +function okAdapter(sink: InternalAxiosRequestConfig[]): AxiosAdapter { + return (config) => { + sink.push(config) + return Promise.resolve({ + data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config, + } as AxiosResponse) + } +} + +/** Rejects the way a CORS/offline failure does: an error with no `response`. */ +const networkErrorAdapter: AxiosAdapter = (config) => + Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config)) + +async function captureError(run: () => Promise): Promise { + const err = await run().then(() => null, (e: unknown) => e as AxiosError) + expect(err, 'expected the request to reject').not.toBeNull() + return err as AxiosError +} + +beforeEach(() => { + probeNow.mockResolvedValue('offline') + setLocation('/dashboard') +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation }) + delete (navigator as { serviceWorker?: unknown }).serviceWorker +}) + +describe('client > request interceptor', () => { + it('FE-APIWIRE-001: mutating requests get an idempotency key, reads do not', async () => { + const sink: InternalAxiosRequestConfig[] = [] + const adapter = okAdapter(sink) + + await apiClient.get('/probe', { adapter }) + await apiClient.post('/probe', {}, { adapter }) + await apiClient.put('/probe', {}, { adapter }) + await apiClient.patch('/probe', {}, { adapter }) + await apiClient.delete('/probe', { adapter }) + + const keys = sink.map(c => c.headers['X-Idempotency-Key']) + expect(keys[0]).toBeUndefined() + for (const key of keys.slice(1)) expect(typeof key).toBe('string') + }) + + it('FE-APIWIRE-002: each write gets its own key so retries can be deduplicated', async () => { + const sink: InternalAxiosRequestConfig[] = [] + const adapter = okAdapter(sink) + + await apiClient.post('/probe', {}, { adapter }) + await apiClient.post('/probe', {}, { adapter }) + + expect(sink[0].headers['X-Idempotency-Key']).not.toBe(sink[1].headers['X-Idempotency-Key']) + }) + + it('FE-APIWIRE-003: a pre-generated key from the mutation queue is left alone', async () => { + const sink: InternalAxiosRequestConfig[] = [] + + await apiClient.post('/probe', {}, { + adapter: okAdapter(sink), + headers: { 'X-Idempotency-Key': 'queued-key' }, + }) + + expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key') + }) + + it('FE-APIWIRE-004: falls back to getRandomValues when crypto.randomUUID is missing', async () => { + const realCrypto = globalThis.crypto + vi.stubGlobal('crypto', { + getRandomValues: realCrypto.getRandomValues.bind(realCrypto), + } as unknown as Crypto) + + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.post('/probe', {}, { adapter: okAdapter(sink) }) + + // randomUUID needs a secure context, so on the plain-http installs the + // README documents this branch is what actually runs. getRandomValues has no + // such requirement, so the fallback is still a full v4 UUID. + expect(String(sink[0].headers['X-Idempotency-Key'])).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ) + }) + + it('FE-APIWIRE-004b: the last-resort key is never degenerate, even with no Web Crypto at all', async () => { + vi.stubGlobal('crypto', undefined) + + const keys = new Set() + for (let i = 0; i < 50; i++) { + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.post('/probe', {}, { adapter: okAdapter(sink) }) + keys.add(String(sink[0].headers['X-Idempotency-Key'])) + } + + // The old fallback was Math.random().toString(36).slice(2), which is not + // length-stable: 0.5 yields a single character and 0 yields the empty string. + // An empty key makes the server skip deduplication entirely, so a retried + // write applies twice. + for (const k of keys) expect(k.length).toBeGreaterThan(16) + expect(keys.size).toBe(50) + }) + + it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => { + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.get('/probe', { adapter: okAdapter(sink) }) + expect(sink[0].headers['X-Socket-Id']).toBeUndefined() + }) + + it('FE-APIWIRE-034: a rejection from an earlier request interceptor is passed on untouched', async () => { + const boom = new Error('interceptor refused the request') + const id = apiClient.interceptors.request.use(() => Promise.reject(boom)) + const sink: InternalAxiosRequestConfig[] = [] + + try { + await expect(apiClient.post('/probe', {}, { adapter: okAdapter(sink) })).rejects.toBe(boom) + } finally { + apiClient.interceptors.request.eject(id) + } + + expect(sink).toHaveLength(0) + }) +}) + +describe('client > rate-limit translation', () => { + beforeEach(() => { + server.use(http.get('/api/limited', () => HttpResponse.json({ error: 'Too Many Requests' }, { status: 429 }))) + }) + + it('FE-APIWIRE-006: a 429 is rewritten in the stored app language', async () => { + localStorage.setItem('app_language', 'de') + const err = await captureError(() => apiClient.get('/limited')) + + expect(err.message).toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.') + expect((err.response?.data as { error: string }).error) + .toBe('Zu viele Versuche. Bitte versuchen Sie es später erneut.') + }) + + it('FE-APIWIRE-007: an unsupported language falls back to English', async () => { + localStorage.setItem('app_language', 'kl') + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-008: no stored language falls back to English', async () => { + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-009: a blocked localStorage still yields the English message', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled') + }) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-010: a non-object 429 body is replaced with the translated error object', async () => { + server.use(http.get('/api/limited', () => new HttpResponse('slow down', { status: 429 }))) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' }) + }) + + it('FE-APIWIRE-035: an array 429 body is replaced, not grafted onto', async () => { + server.use(http.get('/api/limited', () => HttpResponse.json([{ field: 'email' }], { status: 429 }))) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' }) + }) + + it('FE-APIWIRE-036: Catalan, Greek and Vietnamese have their own 429 message', async () => { + for (const lang of ['ca', 'gr', 'vi']) { + localStorage.setItem('app_language', lang) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).not.toBe('Too many attempts. Please try again later.') + } + }) +}) + +describe('client > proxy auth challenges', () => { + function installServiceWorker(unregister: () => Promise) { + const getRegistration = vi.fn(async () => ({ unregister })) + Object.defineProperty(navigator, 'serviceWorker', { + writable: true, configurable: true, value: { getRegistration }, + }) + return getRegistration + } + + it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => { + const unregister = vi.fn(async () => true) + installServiceWorker(unregister) + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(unregister).toHaveBeenCalled() + expect(reload).toHaveBeenCalledTimes(1) + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1') + }) + + it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => { + installServiceWorker(vi.fn(async () => true)) + sessionStorage.setItem('proxy_reauth_attempted', '1') + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-013: an HTML 401 on a public path never reloads', async () => { + setLocation('/login') + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-014: a response-less failure that probes proxy-wall reloads', async () => { + probeNow.mockResolvedValue('proxy-wall') + installServiceWorker(vi.fn(async () => true)) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(probeNow).toHaveBeenCalled() + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-APIWIRE-015: a response-less failure that probes offline keeps the SW (#1346)', async () => { + probeNow.mockResolvedValue('offline') + const getRegistration = installServiceWorker(vi.fn(async () => true)) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(getRegistration).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-016: a failing unregister still reloads into the proxy challenge', async () => { + probeNow.mockResolvedValue('proxy-wall') + Object.defineProperty(navigator, 'serviceWorker', { + writable: true, configurable: true, + value: { getRegistration: vi.fn(async () => { throw new Error('SW gone') }) }, + }) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-APIWIRE-017: a proxy-wall probe on a shared page does not reload', async () => { + setLocation('/shared/tok123') + probeNow.mockResolvedValue('proxy-wall') + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-035: a 401 without a content-type is not mistaken for a proxy login page', async () => { + installServiceWorker(vi.fn(async () => true)) + server.use(http.get('/api/auth/me', () => new HttpResponse(null, { status: 401 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-018: a successful response clears the reauth marker', async () => { + sessionStorage.setItem('proxy_reauth_attempted', '1') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ ok: true }))) + + await apiClient.get('/auth/me') + + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) +}) + +describe('client > redirect handling', () => { + it('FE-APIWIRE-019: a JSON AUTH_REQUIRED 401 redirects with the full current path', async () => { + const loc = setLocation('/trips/7', '?tab=map', '#day-2') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2')) + }) + + it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => { + const loc = setLocation('/dashboard') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(loc.href).toBe('/settings?mfa=required') + }) +}) + +describe('client > dev-only contract drift checks', () => { + it('FE-APIWIRE-021: parseInDev passes a matching payload straight through', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const payload = { temp: 21, main: 'Clear', description: 'clear sky', type: 'sun' } + + expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload) + expect(warn).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-022: parseInDev warns but still returns a drifting payload', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const payload = { temp: 'warm', main: 'Clear', description: 'clear sky', type: 'sun' } + + expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload) + expect(warn).toHaveBeenCalledWith( + '[api] weather.get: response did not match the @trek/shared schema', + expect.anything(), + ) + }) + + it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true }))) + + await expect(mapsApi.search('Rome')).resolves.toEqual({ nonsense: true }) + expect(warn).toHaveBeenCalledWith( + '[api] maps.search: response did not match the @trek/shared schema', + expect.anything(), + ) + }) +}) + +describe('client > pluginsApi.invoke namespace guard', () => { + it('FE-APIWIRE-024: a relative sub-path stays inside the plugin namespace', async () => { + let seen = '' + server.use(http.get('/api/plugins/koffi/ping', ({ request }) => { + seen = new URL(request.url).pathname + return HttpResponse.json({ pong: true }) + })) + + await expect(pluginsApi.invoke('koffi', '/ping')).resolves.toEqual({ pong: true }) + expect(seen).toBe('/api/plugins/koffi/ping') + }) + + it('FE-APIWIRE-025: method, body and query string survive the rewrite', async () => { + let received: unknown + let query = '' + server.use(http.post('/api/plugins/koffi/sync', async ({ request }) => { + received = await request.json() + query = new URL(request.url).search + return HttpResponse.json({ ok: true }) + })) + + await pluginsApi.invoke('koffi', 'sync?full=1', { method: 'POST', body: { since: 5 } }) + + expect(received).toEqual({ since: 5 }) + expect(query).toBe('?full=1') + }) + + it('FE-APIWIRE-026: traversal out of the plugin prefix is refused', async () => { + await expect(pluginsApi.invoke('koffi', '/../../auth/me')) + .rejects.toThrow('plugin route escapes its namespace') + }) + + it('FE-APIWIRE-027: an absolute off-origin target is refused', async () => { + await expect(pluginsApi.invoke('koffi', 'https://evil.test/steal')) + .rejects.toThrow('plugin route escapes its namespace') + }) + + it('FE-APIWIRE-028: an unparseable sub-path is refused before any request', async () => { + await expect(pluginsApi.invoke('koffi', 'http://')).rejects.toThrow('invalid plugin route') + }) +}) + +describe('client > adminApi.llmLocalPull', () => { + function streamingResponse(chunks: string[]): Response { + let i = 0 + const encoder = new TextEncoder() + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => (i < chunks.length + ? { done: false, value: encoder.encode(chunks[i++]) } + : { done: true, value: undefined }), + cancel: async () => {}, + }), + }, + } as unknown as Response + } + + it('FE-APIWIRE-029: NDJSON progress lines are reported even when split across chunks', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([ + '{"status":"pulling","total":100,"completed":10}\n{"status":"pul', + 'ling","total":100,"completed":90}\n{"status":"success"}\n', + ])) + const onProgress = vi.fn((_p: { status?: string }) => {}) + + await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress) + + expect(onProgress.mock.calls.map(c => c[0])).toEqual([ + { status: 'pulling', total: 100, completed: 10 }, + { status: 'pulling', total: 100, completed: 90 }, + { status: 'success' }, + ]) + }) + + it('FE-APIWIRE-030: blank and half-written lines are skipped instead of throwing', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([ + '\n \n{"status":"a"}\nnot-json\n{"status":"b"}\n', + ])) + const onProgress = vi.fn((_p: { status?: string }) => {}) + + await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress) + + expect(onProgress.mock.calls.map(c => c[0])).toEqual([{ status: 'a' }, { status: 'b' }]) + }) + + it('FE-APIWIRE-031: a JSON error body becomes the thrown message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, status: 502, body: null, + json: async () => ({ error: 'ollama unreachable' }), + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('ollama unreachable') + }) + + it('FE-APIWIRE-032: a non-JSON error body falls back to the status code', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, status: 500, body: null, + json: async () => { throw new SyntaxError('not json') }, + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('Pull failed (500)') + }) + + it('FE-APIWIRE-036: a throw from onProgress aborts the pull', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([ + '{"status":"pulling manifest"}\n{"error":"manifest not found"}\n{"status":"success"}\n', + ])) + const onProgress = vi.fn((p: { error?: string }) => { + if (p.error) throw new Error(p.error) + }) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', onProgress)) + .rejects.toThrow('manifest not found') + expect(onProgress).toHaveBeenCalledTimes(2) + }) + + it('FE-APIWIRE-033: a 200 without a readable body reports the missing stream', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, status: 200, body: null, + json: async () => ({}), + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('Pull returned no progress stream') + }) +}) diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts new file mode 100644 index 000000000..bca591154 --- /dev/null +++ b/client/src/api/client.surface.test.ts @@ -0,0 +1,865 @@ +// FE-APISURF-001 to FE-APISURF-054 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { AxiosResponse } from 'axios' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { + apiClient, + authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi, + tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi, + mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, + configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, + shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi, +} from './client' + +interface Recorded { method: string; url: string; body: unknown } + +let log: Recorded[] = [] + +/** One record per outgoing request: verb, path+query and (parsed) JSON body. */ +function recorder() { + return http.all(/\/api\//, async ({ request }) => { + const url = new URL(request.url) + const raw = await request.text() + let body: unknown + if (raw) { + try { body = JSON.parse(raw) } catch { body = raw } + } + log.push({ method: request.method, url: url.pathname + url.search, body }) + return HttpResponse.json({ ok: true }) + }) +} + +beforeEach(() => { + log = [] + server.use(recorder()) + // parseInDev/checkInDev warn on every stub payload that doesn't match its + // @trek/shared schema — expected here, so keep the output readable. + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface Call { n: string; r: () => Promise; e: string } + +/** Runs every call in isolation and checks the verb + path it produced. */ +async function assertCalls(calls: Call[]): Promise { + for (const c of calls) { + log = [] + await c.r() + expect(log.length, `${c.n}: expected exactly one request`).toBe(1) + const rec = log[0] + const [path] = rec.url.split('?') + expect(`${rec.method} ${path}`, c.n).toBe(c.e) + } +} + +/** Runs one call and returns the request it produced. */ +async function traceOne(run: () => Promise): Promise { + log = [] + await run() + expect(log).toHaveLength(1) + return log[0] +} + +describe('client > endpoint wiring', () => { + it('FE-APISURF-001: authApi maps every method to its auth endpoint', async () => { + await assertCalls([ + { n: 'register', r: () => authApi.register({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/register' }, + { n: 'validateInvite', r: () => authApi.validateInvite('inv-tok'), e: 'GET /api/auth/invite/inv-tok' }, + { n: 'login', r: () => authApi.login({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/login' }, + { n: 'verifyMfaLogin', r: () => authApi.verifyMfaLogin({ mfa_token: 'm', code: '123456' }), e: 'POST /api/auth/mfa/verify-login' }, + { n: 'mfaSetup', r: () => authApi.mfaSetup(), e: 'POST /api/auth/mfa/setup' }, + { n: 'mfaEnable', r: () => authApi.mfaEnable({ code: '123456' }), e: 'POST /api/auth/mfa/enable' }, + { n: 'mfaDisable', r: () => authApi.mfaDisable({ password: 'pw', code: '123456' }), e: 'POST /api/auth/mfa/disable' }, + { n: 'me', r: () => authApi.me(), e: 'GET /api/auth/me' }, + { n: 'updateMapsKey', r: () => authApi.updateMapsKey('gkey'), e: 'PUT /api/auth/me/maps-key' }, + { n: 'updateApiKeys', r: () => authApi.updateApiKeys({ google_maps: null }), e: 'PUT /api/auth/me/api-keys' }, + { n: 'updateSettings', r: () => authApi.updateSettings({ theme: 'dark' }), e: 'PUT /api/auth/me/settings' }, + { n: 'getSettings', r: () => authApi.getSettings(), e: 'GET /api/auth/me/settings' }, + { n: 'listUsers', r: () => authApi.listUsers(), e: 'GET /api/auth/users' }, + { n: 'deleteAvatar', r: () => authApi.deleteAvatar(), e: 'DELETE /api/auth/avatar' }, + { n: 'getAppConfig', r: () => authApi.getAppConfig(), e: 'GET /api/auth/app-config' }, + { n: 'updateAppSettings', r: () => authApi.updateAppSettings({ registration_enabled: true }), e: 'PUT /api/auth/app-settings' }, + { n: 'validateKeys', r: () => authApi.validateKeys(), e: 'GET /api/auth/validate-keys' }, + { n: 'travelStats', r: () => authApi.travelStats(), e: 'GET /api/auth/travel-stats' }, + { n: 'changePassword', r: () => authApi.changePassword({ current_password: 'a', new_password: 'b' }), e: 'PUT /api/auth/me/password' }, + { n: 'forgotPassword', r: () => authApi.forgotPassword({ email: 'a@b.c' }), e: 'POST /api/auth/forgot-password' }, + { n: 'resetPassword', r: () => authApi.resetPassword({ token: 't', new_password: 'b' }), e: 'POST /api/auth/reset-password' }, + { n: 'deleteOwnAccount', r: () => authApi.deleteOwnAccount(), e: 'DELETE /api/auth/me' }, + { n: 'demoLogin', r: () => authApi.demoLogin(), e: 'POST /api/auth/demo-login' }, + { n: 'mcpTokens.list', r: () => authApi.mcpTokens.list(), e: 'GET /api/auth/mcp-tokens' }, + { n: 'mcpTokens.create', r: () => authApi.mcpTokens.create('cli'), e: 'POST /api/auth/mcp-tokens' }, + { n: 'mcpTokens.delete', r: () => authApi.mcpTokens.delete(7), e: 'DELETE /api/auth/mcp-tokens/7' }, + { n: 'passkey.registerOptions', r: () => authApi.passkey.registerOptions('pw'), e: 'POST /api/auth/passkey/register/options' }, + { n: 'passkey.registerVerify', r: () => authApi.passkey.registerVerify({ id: 'cred' }, 'Yubikey'), e: 'POST /api/auth/passkey/register/verify' }, + { n: 'passkey.loginOptions', r: () => authApi.passkey.loginOptions(), e: 'POST /api/auth/passkey/login/options' }, + { n: 'passkey.loginVerify', r: () => authApi.passkey.loginVerify({ id: 'cred' }), e: 'POST /api/auth/passkey/login/verify' }, + { n: 'passkey.list', r: () => authApi.passkey.list(), e: 'GET /api/auth/passkey/credentials' }, + { n: 'passkey.rename', r: () => authApi.passkey.rename(3, 'Phone'), e: 'PATCH /api/auth/passkey/credentials/3' }, + { n: 'passkey.delete', r: () => authApi.passkey.delete(3, 'pw'), e: 'DELETE /api/auth/passkey/credentials/3' }, + ]) + }) + + it('FE-APISURF-002: oauthApi maps consent + client/session management endpoints', async () => { + const params = { + response_type: 'code', client_id: 'cid', redirect_uri: 'https://app/cb', + scope: 'trips:read', code_challenge: 'chal', code_challenge_method: 'S256', + } + await assertCalls([ + { n: 'validate', r: () => oauthApi.validate(params), e: 'GET /api/oauth/authorize/validate' }, + { n: 'authorize', r: () => oauthApi.authorize({ ...params, approved: true }), e: 'POST /api/oauth/authorize' }, + { n: 'clients.list', r: () => oauthApi.clients.list(), e: 'GET /api/oauth/clients' }, + { n: 'clients.create', r: () => oauthApi.clients.create({ name: 'App', allowed_scopes: ['trips:read'] }), e: 'POST /api/oauth/clients' }, + { n: 'clients.rotate', r: () => oauthApi.clients.rotate('cid'), e: 'POST /api/oauth/clients/cid/rotate' }, + { n: 'clients.delete', r: () => oauthApi.clients.delete('cid'), e: 'DELETE /api/oauth/clients/cid' }, + { n: 'sessions.list', r: () => oauthApi.sessions.list(), e: 'GET /api/oauth/sessions' }, + { n: 'sessions.revoke', r: () => oauthApi.sessions.revoke(4), e: 'DELETE /api/oauth/sessions/4' }, + ]) + }) + + it('FE-APISURF-003: tripsApi maps trip, member and guest endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => tripsApi.list(), e: 'GET /api/trips' }, + { n: 'create', r: () => tripsApi.create({ title: 'Rome' }), e: 'POST /api/trips' }, + { n: 'get', r: () => tripsApi.get(3), e: 'GET /api/trips/3' }, + { n: 'update', r: () => tripsApi.update(3, { title: 'Rome 2' }), e: 'PUT /api/trips/3' }, + { n: 'delete', r: () => tripsApi.delete(3), e: 'DELETE /api/trips/3' }, + { n: 'searchCoverImages', r: () => tripsApi.searchCoverImages('rome'), e: 'GET /api/trips/cover-images/search' }, + { n: 'archive', r: () => tripsApi.archive(3), e: 'PUT /api/trips/3' }, + { n: 'unarchive', r: () => tripsApi.unarchive(3), e: 'PUT /api/trips/3' }, + { n: 'getMembers', r: () => tripsApi.getMembers(3), e: 'GET /api/trips/3/members' }, + { n: 'addMember', r: () => tripsApi.addMember(3, 'bob'), e: 'POST /api/trips/3/members' }, + { n: 'removeMember', r: () => tripsApi.removeMember(3, 9), e: 'DELETE /api/trips/3/members/9' }, + { n: 'transferOwnership', r: () => tripsApi.transferOwnership(3, 9), e: 'POST /api/trips/3/transfer' }, + { n: 'createGuest', r: () => tripsApi.createGuest(3, 'Anna'), e: 'POST /api/trips/3/guests' }, + { n: 'renameGuest', r: () => tripsApi.renameGuest(3, 9, 'Ana'), e: 'PUT /api/trips/3/guests/9' }, + { n: 'deleteGuest', r: () => tripsApi.deleteGuest(3, 9), e: 'DELETE /api/trips/3/guests/9' }, + { n: 'copy', r: () => tripsApi.copy(3, { title: 'Copy' }), e: 'POST /api/trips/3/copy' }, + { n: 'bundle', r: () => tripsApi.bundle(3), e: 'GET /api/trips/3/bundle' }, + ]) + }) + + it('FE-APISURF-004: daysApi and dayNotesApi map their nested trip endpoints', async () => { + await assertCalls([ + { n: 'days.list', r: () => daysApi.list(1), e: 'GET /api/trips/1/days' }, + { n: 'days.create', r: () => daysApi.create(1, { date: '2026-06-01' }), e: 'POST /api/trips/1/days' }, + { n: 'days.update', r: () => daysApi.update(1, 2, { notes: 'hi' }), e: 'PUT /api/trips/1/days/2' }, + { n: 'days.updateTransport', r: () => daysApi.updateTransport(1, 2, 'car'), e: 'PUT /api/trips/1/days/2/transport' }, + { n: 'days.delete', r: () => daysApi.delete(1, 2), e: 'DELETE /api/trips/1/days/2' }, + { n: 'days.reorder', r: () => daysApi.reorder(1, [2, 1]), e: 'PUT /api/trips/1/days/reorder' }, + { n: 'dayNotes.list', r: () => dayNotesApi.list(1, 2), e: 'GET /api/trips/1/days/2/notes' }, + { n: 'dayNotes.create', r: () => dayNotesApi.create(1, 2, { text: 'note' }), e: 'POST /api/trips/1/days/2/notes' }, + { n: 'dayNotes.update', r: () => dayNotesApi.update(1, 2, 5, { text: 'edit' }), e: 'PUT /api/trips/1/days/2/notes/5' }, + { n: 'dayNotes.delete', r: () => dayNotesApi.delete(1, 2, 5), e: 'DELETE /api/trips/1/days/2/notes/5' }, + ]) + }) + + it('FE-APISURF-005: placesApi maps CRUD, rating and list-import endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => placesApi.list(1), e: 'GET /api/trips/1/places' }, + { n: 'create', r: () => placesApi.create(1, { name: 'Colosseum' }), e: 'POST /api/trips/1/places' }, + { n: 'get', r: () => placesApi.get(1, 5), e: 'GET /api/trips/1/places/5' }, + { n: 'update', r: () => placesApi.update(1, 5, { name: 'Forum' }), e: 'PUT /api/trips/1/places/5' }, + { n: 'delete', r: () => placesApi.delete(1, 5), e: 'DELETE /api/trips/1/places/5' }, + { n: 'searchImage', r: () => placesApi.searchImage(1, 5), e: 'GET /api/trips/1/places/5/image' }, + { n: 'importGoogleList', r: () => placesApi.importGoogleList(1, 'https://maps.app/x'), e: 'POST /api/trips/1/places/import/google-list' }, + { n: 'importNaverList', r: () => placesApi.importNaverList(1, 'https://naver/x'), e: 'POST /api/trips/1/places/import/naver-list' }, + { n: 'bulkDelete', r: () => placesApi.bulkDelete(1, [5, 6]), e: 'POST /api/trips/1/places/bulk-delete' }, + { n: 'bulkUpdate', r: () => placesApi.bulkUpdate(1, [5], { category_id: 2 }), e: 'POST /api/trips/1/places/bulk-update' }, + ]) + }) + + it('FE-APISURF-006: assignmentsApi maps day-plan endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => assignmentsApi.list(1, 2), e: 'GET /api/trips/1/days/2/assignments' }, + { n: 'create', r: () => assignmentsApi.create(1, 2, { place_id: 5 }), e: 'POST /api/trips/1/days/2/assignments' }, + { n: 'delete', r: () => assignmentsApi.delete(1, 2, 7), e: 'DELETE /api/trips/1/days/2/assignments/7' }, + { n: 'reorder', r: () => assignmentsApi.reorder(1, 2, [7, 8]), e: 'PUT /api/trips/1/days/2/assignments/reorder' }, + { n: 'move', r: () => assignmentsApi.move(1, 7, 3, 0), e: 'PUT /api/trips/1/assignments/7/move' }, + { n: 'update', r: () => assignmentsApi.update(1, 2, 7, { notes: 'x' }), e: 'PUT /api/trips/1/days/2/assignments/7' }, + { n: 'getParticipants', r: () => assignmentsApi.getParticipants(1, 7), e: 'GET /api/trips/1/assignments/7/participants' }, + { n: 'setParticipants', r: () => assignmentsApi.setParticipants(1, 7, [4]), e: 'PUT /api/trips/1/assignments/7/participants' }, + { n: 'updateTime', r: () => assignmentsApi.updateTime(1, 7, { place_time: '09:00' }), e: 'PUT /api/trips/1/assignments/7/time' }, + { n: 'updateTransport', r: () => assignmentsApi.updateTransport(1, 7, null), e: 'PUT /api/trips/1/assignments/7/transport' }, + ]) + }) + + it('FE-APISURF-007: packingApi maps item, bag and template endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => packingApi.list(1), e: 'GET /api/trips/1/packing' }, + { n: 'create', r: () => packingApi.create(1, { name: 'Towel' }), e: 'POST /api/trips/1/packing' }, + { n: 'bulkImport', r: () => packingApi.bulkImport(1, [{ name: 'Socks' }]), e: 'POST /api/trips/1/packing/import' }, + { n: 'update', r: () => packingApi.update(1, 4, { checked: true }), e: 'PUT /api/trips/1/packing/4' }, + { n: 'delete', r: () => packingApi.delete(1, 4), e: 'DELETE /api/trips/1/packing/4' }, + { n: 'reorder', r: () => packingApi.reorder(1, [4, 5]), e: 'PUT /api/trips/1/packing/reorder' }, + { n: 'setSharing', r: () => packingApi.setSharing(1, 4, { visibility: 'shared' }), e: 'PUT /api/trips/1/packing/4/sharing' }, + { n: 'clone', r: () => packingApi.clone(1, 4), e: 'POST /api/trips/1/packing/4/clone' }, + { n: 'addContributor', r: () => packingApi.addContributor(1, 4), e: 'POST /api/trips/1/packing/4/contributors' }, + { n: 'removeContributor', r: () => packingApi.removeContributor(1, 4, 9), e: 'DELETE /api/trips/1/packing/4/contributors/9' }, + { n: 'getCategoryAssignees', r: () => packingApi.getCategoryAssignees(1), e: 'GET /api/trips/1/packing/category-assignees' }, + { n: 'listTemplates', r: () => packingApi.listTemplates(1), e: 'GET /api/trips/1/packing/templates' }, + { n: 'applyTemplate', r: () => packingApi.applyTemplate(1, 6), e: 'POST /api/trips/1/packing/apply-template/6' }, + { n: 'saveAsTemplate', r: () => packingApi.saveAsTemplate(1, 'Beach'), e: 'POST /api/trips/1/packing/save-as-template' }, + { n: 'setBagMembers', r: () => packingApi.setBagMembers(1, 2, [9]), e: 'PUT /api/trips/1/packing/bags/2/members' }, + { n: 'listBags', r: () => packingApi.listBags(1), e: 'GET /api/trips/1/packing/bags' }, + { n: 'createBag', r: () => packingApi.createBag(1, { name: 'Carry-on' }), e: 'POST /api/trips/1/packing/bags' }, + { n: 'updateBag', r: () => packingApi.updateBag(1, 2, { name: 'Hold' }), e: 'PUT /api/trips/1/packing/bags/2' }, + { n: 'deleteBag', r: () => packingApi.deleteBag(1, 2), e: 'DELETE /api/trips/1/packing/bags/2' }, + ]) + }) + + it('FE-APISURF-008: todoApi maps todo endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => todoApi.list(1), e: 'GET /api/trips/1/todo' }, + { n: 'create', r: () => todoApi.create(1, { name: 'Book train' }), e: 'POST /api/trips/1/todo' }, + { n: 'update', r: () => todoApi.update(1, 3, { checked: true }), e: 'PUT /api/trips/1/todo/3' }, + { n: 'delete', r: () => todoApi.delete(1, 3), e: 'DELETE /api/trips/1/todo/3' }, + { n: 'reorder', r: () => todoApi.reorder(1, [3, 4]), e: 'PUT /api/trips/1/todo/reorder' }, + { n: 'getCategoryAssignees', r: () => todoApi.getCategoryAssignees(1), e: 'GET /api/trips/1/todo/category-assignees' }, + ]) + }) + + it('FE-APISURF-009: tagsApi and categoriesApi map their global endpoints', async () => { + await assertCalls([ + { n: 'tags.list', r: () => tagsApi.list(), e: 'GET /api/tags' }, + { n: 'tags.create', r: () => tagsApi.create({ name: 'Food' }), e: 'POST /api/tags' }, + { n: 'tags.update', r: () => tagsApi.update(2, { name: 'Eat' }), e: 'PUT /api/tags/2' }, + { n: 'tags.delete', r: () => tagsApi.delete(2), e: 'DELETE /api/tags/2' }, + { n: 'categories.list', r: () => categoriesApi.list(), e: 'GET /api/categories' }, + { n: 'categories.create', r: () => categoriesApi.create({ name: 'Museum' }), e: 'POST /api/categories' }, + { n: 'categories.update', r: () => categoriesApi.update(2, { name: 'Art' }), e: 'PUT /api/categories/2' }, + { n: 'categories.delete', r: () => categoriesApi.delete(2), e: 'DELETE /api/categories/2' }, + ]) + }) + + it('FE-APISURF-010: adminApi maps user, addon and settings endpoints', async () => { + await assertCalls([ + { n: 'users', r: () => adminApi.users(), e: 'GET /api/admin/users' }, + { n: 'createUser', r: () => adminApi.createUser({ email: 'a@b.c' }), e: 'POST /api/admin/users' }, + { n: 'updateUser', r: () => adminApi.updateUser(2, { role: 'admin' }), e: 'PUT /api/admin/users/2' }, + { n: 'deleteUser', r: () => adminApi.deleteUser(2), e: 'DELETE /api/admin/users/2' }, + { n: 'resetUserPasskeys', r: () => adminApi.resetUserPasskeys(2), e: 'DELETE /api/admin/users/2/passkeys' }, + { n: 'stats', r: () => adminApi.stats(), e: 'GET /api/admin/stats' }, + { n: 'saveDemoBaseline', r: () => adminApi.saveDemoBaseline(), e: 'POST /api/admin/save-demo-baseline' }, + { n: 'getOidc', r: () => adminApi.getOidc(), e: 'GET /api/admin/oidc' }, + { n: 'updateOidc', r: () => adminApi.updateOidc({ enabled: true }), e: 'PUT /api/admin/oidc' }, + { n: 'addons', r: () => adminApi.addons(), e: 'GET /api/admin/addons' }, + { n: 'updateAddon', r: () => adminApi.updateAddon(3, { enabled: false }), e: 'PUT /api/admin/addons/3' }, + { n: 'checkVersion', r: () => adminApi.checkVersion(), e: 'GET /api/admin/version-check' }, + { n: 'getBagTracking', r: () => adminApi.getBagTracking(), e: 'GET /api/admin/bag-tracking' }, + { n: 'updateBagTracking', r: () => adminApi.updateBagTracking(true), e: 'PUT /api/admin/bag-tracking' }, + { n: 'getPlacesPhotos', r: () => adminApi.getPlacesPhotos(), e: 'GET /api/admin/places-photos' }, + { n: 'updatePlacesPhotos', r: () => adminApi.updatePlacesPhotos(false), e: 'PUT /api/admin/places-photos' }, + { n: 'getPlacesAutocomplete', r: () => adminApi.getPlacesAutocomplete(), e: 'GET /api/admin/places-autocomplete' }, + { n: 'updatePlacesAutocomplete', r: () => adminApi.updatePlacesAutocomplete(true), e: 'PUT /api/admin/places-autocomplete' }, + { n: 'getPlacesDetails', r: () => adminApi.getPlacesDetails(), e: 'GET /api/admin/places-details' }, + { n: 'updatePlacesDetails', r: () => adminApi.updatePlacesDetails(true), e: 'PUT /api/admin/places-details' }, + { n: 'getCollabFeatures', r: () => adminApi.getCollabFeatures(), e: 'GET /api/admin/collab-features' }, + { n: 'updateCollabFeatures', r: () => adminApi.updateCollabFeatures({ polls: true }), e: 'PUT /api/admin/collab-features' }, + { n: 'getPermissions', r: () => adminApi.getPermissions(), e: 'GET /api/admin/permissions' }, + { n: 'updatePermissions', r: () => adminApi.updatePermissions({ edit_trip: 'member' }), e: 'PUT /api/admin/permissions' }, + { n: 'rotateJwtSecret', r: () => adminApi.rotateJwtSecret(), e: 'POST /api/admin/rotate-jwt-secret' }, + { n: 'sendTestNotification', r: () => adminApi.sendTestNotification({ channel: 'email' }), e: 'POST /api/admin/dev/test-notification' }, + { n: 'getNotificationPreferences', r: () => adminApi.getNotificationPreferences(), e: 'GET /api/admin/notification-preferences' }, + { n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' }, + { n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' }, + { n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' }, + { n: 'getStorage', r: () => adminApi.getStorage(), e: 'GET /api/admin/storage' }, + { n: 'updateStorage', r: () => adminApi.updateStorage({ backends: [], categories: {}, version: 0 }), e: 'PUT /api/admin/storage' }, + { n: 'testStorageBackend', r: () => adminApi.testStorageBackend({ name: 'x', type: 'local', options: { root: '/data' } }), e: 'POST /api/admin/storage/test' }, + { n: 'startStorageBackfill', r: () => adminApi.startStorageBackfill('m'), e: 'POST /api/admin/storage/backends/m/backfill' }, + { n: 'cancelStorageBackfill', r: () => adminApi.cancelStorageBackfill('m'), e: 'DELETE /api/admin/storage/backends/m/backfill' }, + { n: 'startStorageMigration', r: () => adminApi.startStorageMigration('files', 'dest'), e: 'POST /api/admin/storage/migrations' }, + { n: 'cancelStorageMigration', r: () => adminApi.cancelStorageMigration('files'), e: 'DELETE /api/admin/storage/migrations/files' }, + { n: 'refreshStorageStats', r: () => adminApi.refreshStorageStats(), e: 'POST /api/admin/storage/stats/refresh' }, + { n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' }, + { n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' }, + { n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' }, + { n: 'revokeOAuthSession', r: () => adminApi.revokeOAuthSession(4), e: 'DELETE /api/admin/oauth-sessions/4' }, + { n: 'listInvites', r: () => adminApi.listInvites(), e: 'GET /api/admin/invites' }, + { n: 'listInviteTrips', r: () => adminApi.listInviteTrips(), e: 'GET /api/admin/invites/trips' }, + { n: 'createInvite', r: () => adminApi.createInvite({ max_uses: 3 }), e: 'POST /api/admin/invites' }, + { n: 'deleteInvite', r: () => adminApi.deleteInvite(8), e: 'DELETE /api/admin/invites/8' }, + { n: 'auditLog', r: () => adminApi.auditLog(), e: 'GET /api/admin/audit-log' }, + ]) + }) + + it('FE-APISURF-011: adminApi maps the plugin management endpoints', async () => { + await assertCalls([ + { n: 'plugins', r: () => adminApi.plugins(), e: 'GET /api/admin/plugins' }, + { n: 'pluginBrowse', r: () => adminApi.pluginBrowse(), e: 'GET /api/admin/plugins/registry' }, + { n: 'pluginDetail', r: () => adminApi.pluginDetail('trek/koffi'), e: 'GET /api/admin/plugins/registry/trek%2Fkoffi' }, + { n: 'pluginInstall', r: () => adminApi.pluginInstall('koffi', { version: '1.0.0' }), e: 'POST /api/admin/plugins/install' }, + { n: 'pluginActivate', r: () => adminApi.pluginActivate('koffi'), e: 'POST /api/admin/plugins/koffi/activate' }, + { n: 'pluginDeactivate', r: () => adminApi.pluginDeactivate('koffi'), e: 'POST /api/admin/plugins/koffi/deactivate' }, + { n: 'pluginUpdate', r: () => adminApi.pluginUpdate('koffi'), e: 'POST /api/admin/plugins/koffi/update' }, + { n: 'pluginRetrust', r: () => adminApi.pluginRetrust('koffi', '2.0.0', 'PUBKEY'), e: 'POST /api/admin/plugins/koffi/retrust' }, + { n: 'pluginUninstall', r: () => adminApi.pluginUninstall('koffi', true), e: 'POST /api/admin/plugins/koffi/uninstall' }, + { n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' }, + { n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' }, + { n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' }, + { n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' }, + { n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' }, + { n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' }, + { n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' }, + { n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' }, + ]) + }) + + it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => { + await assertCalls([ + { n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' }, + { n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' }, + { n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' }, + { n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' }, + { n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' }, + { n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' }, + { n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' }, + { n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' }, + { n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' }, + { n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' }, + { n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' }, + ]) + }) + + it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => { + await assertCalls([ + { n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' }, + { n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' }, + { n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' }, + { n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' }, + { n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' }, + { n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' }, + { n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' }, + { n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' }, + { n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' }, + { n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' }, + { n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' }, + { n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' }, + { n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' }, + { n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' }, + { n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' }, + { n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' }, + { n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' }, + { n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' }, + { n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' }, + ]) + }) + + it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => { + await assertCalls([ + { n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' }, + { n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' }, + { n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' }, + { n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' }, + { n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' }, + { n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' }, + { n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' }, + ]) + }) + + it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' }, + { n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' }, + { n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' }, + { n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' }, + { n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' }, + { n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' }, + { n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' }, + { n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' }, + { n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' }, + { n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' }, + { n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' }, + { n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' }, + { n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' }, + { n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' }, + { n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' }, + { n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' }, + { n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' }, + { n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' }, + { n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' }, + { n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' }, + { n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' }, + { n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' }, + { n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' }, + { n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' }, + { n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' }, + { n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' }, + { n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' }, + { n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' }, + { n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' }, + { n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' }, + ]) + }) + + it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => { + await assertCalls([ + { n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' }, + { n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' }, + { n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' }, + { n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' }, + ]) + }) + + it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => { + const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?')) + expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F') + }) + + it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => { + await assertCalls([ + { n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' }, + { n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' }, + { n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' }, + { n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' }, + { n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' }, + { n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' }, + { n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' }, + { n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' }, + { n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' }, + ]) + }) + + it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' }, + { n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' }, + { n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' }, + { n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' }, + { n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' }, + { n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' }, + { n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' }, + { n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' }, + { n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' }, + { n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' }, + { n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' }, + { n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' }, + { n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' }, + { n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' }, + ]) + }) + + it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' }, + { n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' }, + { n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' }, + { n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' }, + { n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' }, + { n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' }, + { n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' }, + { n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' }, + { n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' }, + { n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' }, + ]) + }) + + it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => { + await assertCalls([ + { n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' }, + { n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' }, + { n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' }, + { n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' }, + { n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' }, + { n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' }, + { n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' }, + { n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' }, + { n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' }, + { n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' }, + { n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' }, + { n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' }, + { n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' }, + ]) + }) + + it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => { + await assertCalls([ + { n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' }, + { n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' }, + { n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' }, + { n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' }, + { n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' }, + { n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' }, + { n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' }, + { n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' }, + { n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' }, + { n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' }, + { n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' }, + { n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' }, + { n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' }, + { n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, '👍'), e: 'POST /api/trips/1/collab/messages/2/react' }, + { n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' }, + ]) + }) + + it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => { + await assertCalls([ + { n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' }, + { n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' }, + { n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' }, + { n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' }, + { n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' }, + { n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' }, + { n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' }, + { n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' }, + { n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' }, + { n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' }, + { n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' }, + { n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' }, + { n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' }, + { n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' }, + { n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' }, + { n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' }, + { n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' }, + { n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' }, + { n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' }, + { n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' }, + { n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' }, + { n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' }, + { n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' }, + { n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' }, + { n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' }, + { n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' }, + { n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' }, + { n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' }, + { n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' }, + { n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' }, + { n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' }, + { n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' }, + { n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' }, + { n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' }, + { n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' }, + { n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' }, + { n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' }, + { n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' }, + { n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' }, + { n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' }, + { n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' }, + { n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' }, + ]) + }) +}) + +describe('client > request payloads', () => { + it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => { + expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] }) + expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] }) + expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] }) + expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] }) + expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body) + .toEqual({ orderedCategories: ['Food', 'Fun'] }) + expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) + }) + + it('FE-APISURF-023: user-id collections are sent as user_ids', async () => { + expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] }) + expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] }) + expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] }) + expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] }) + }) + + it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => { + expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null }) + expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' }) + expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 }) + expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' }) + expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' }) + expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null }) + expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 }) + expect((await traceOne(() => collabApi.reactMessage(1, 2, '🎉'))).body).toEqual({ emoji: '🎉' }) + expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' }) + expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } }) + expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false }) + expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true }) + expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body) + .toEqual({ permissions: { edit: 'owner' } }) + expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body) + .toEqual({ config: { k: 'v' } }) + }) + + it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => { + expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true }) + expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false }) + }) + + it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => { + expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] }) + expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body) + .toEqual({ ids: [5], category_id: null }) + }) + + it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => { + const cleared = await traceOne(() => placesApi.rate(1, 5, null)) + expect(cleared.method).toBe('DELETE') + expect(cleared.url).toBe('/api/trips/1/places/5/rating') + + const set = await traceOne(() => placesApi.rate(1, 5, 4)) + expect(set.method).toBe('PUT') + expect(set.url).toBe('/api/trips/1/places/5/rating') + expect(set.body).toEqual({ rating: 4 }) + }) + + it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => { + expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] }) + expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] }) + expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body) + .toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] }) + }) + + it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => { + expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'] }) + expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] }) + expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body) + .toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' }) + expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' }) + expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] }) + }) + + it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => { + expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({}) + expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true }) + }) + + it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => { + expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' }) + expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body) + .toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true }) + }) + + it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => { + expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null }) + expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 }) + }) + + it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => { + expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({}) + expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({}) + }) + + it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => { + const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2')) + expect(rec.method).toBe('DELETE') + expect(rec.body).toEqual({ password: 'hunter2' }) + }) +}) + +describe('client > query parameters', () => { + it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => { + const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' })) + const qs = new URLSearchParams(rec.url.split('?')[1]) + expect(qs.get('archived')).toBe('true') + expect(qs.get('q')).toBe('rome') + }) + + it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => { + expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files') + expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true') + }) + + it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => { + expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement') + expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR') + }) + + it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => { + expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages') + expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url) + .toBe('/api/trips/1/collab/messages?before=2026-01-01') + }) + + it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => { + expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry') + expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1') + }) + + it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => { + const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 })) + expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50') + expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100') + + const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434')) + expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434') + }) + + it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => { + const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de')) + const qs = new URLSearchParams(rec.url.split('?')[1]) + expect(qs.get('category')).toBe('cafe') + expect(qs.get('south')).toBe('41.8') + expect(qs.get('west')).toBe('12.4') + expect(qs.get('north')).toBe('42') + expect(qs.get('east')).toBe('12.6') + expect(qs.get('lang')).toBe('de') + }) + + it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => { + const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01')) + const fq = new URLSearchParams(forecast.url.split('?')[1]) + expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01']) + + const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de')) + expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de') + }) + + it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => { + expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url) + .toBe('/api/trip-card-contributions?tripIds=1,2,3') + expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200') + expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5') + }) + + it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => { + const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4])) + expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet') + expect(packing.body).toEqual({ user_ids: [4] }) + + const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5])) + expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after') + expect(todo.body).toEqual({ user_ids: [5] }) + }) + + it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => { + const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2')) + expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2') + }) +}) + +describe('client > multipart uploads', () => { + // jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the + // axios boundary instead (same approach as tests/integration/api/client.test.ts). + function spyPost() { + return vi.spyOn(apiClient, 'post') + .mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + } + + it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => { + const post = spyPost() + const fd = new FormData() + + await authApi.uploadAvatar(fd) + await tripsApi.uploadCover(3, fd) + await filesApi.upload(1, fd) + await journeyApi.uploadPhotos(9, fd) + await journeyApi.uploadGalleryPhotos(2, fd) + await journeyApi.uploadGalleryVideo(2, fd) + await journeyApi.uploadCover(2, fd) + await collabApi.uploadNoteFile(1, 2, fd) + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/auth/avatar', + '/trips/3/cover', + '/trips/1/files', + '/journeys/entries/9/photos', + '/journeys/2/gallery/photos', + '/journeys/2/gallery/video', + '/journeys/2/cover', + '/trips/1/collab/notes/2/files', + ]) + for (const call of post.mock.calls) { + expect(call[1]).toBeInstanceOf(FormData) + expect(call[2]).toMatchObject({ timeout: 0 }) + expect((call[2] as { headers: Record }).headers['Content-Type']).toBe('multipart/form-data') + } + }) + + it('FE-APISURF-047: postMultipart forwards progress, abort signal and idempotency key', async () => { + const post = spyPost() + const onUploadProgress = vi.fn((_e: unknown) => {}) + const controller = new AbortController() + + await filesApi.upload(1, new FormData(), { + onUploadProgress, + signal: controller.signal, + idempotencyKey: 'fixed-key', + }) + + const config = post.mock.calls[0][2] as { + headers: Record + onUploadProgress?: unknown + signal?: AbortSignal + timeout: number + } + expect(config.headers['X-Idempotency-Key']).toBe('fixed-key') + expect(config.onUploadProgress).toBe(onUploadProgress) + expect(config.signal).toBe(controller.signal) + expect(config.timeout).toBe(0) + }) + + it('FE-APISURF-048: placesApi.uploadImage posts the file under the image field', async () => { + const post = spyPost() + const file = new File(['bytes'], 'shot.jpg', { type: 'image/jpeg' }) + + await placesApi.uploadImage(1, 5, file) + + expect(post.mock.calls[0][0]).toBe('/trips/1/places/5/image') + const fd = post.mock.calls[0][1] as FormData + expect((fd.get('image') as File).name).toBe('shot.jpg') + }) + + it('FE-APISURF-049: placesApi.importGpx only appends the flags it was given', async () => { + const post = spyPost() + const file = new File([''], 'track.gpx') + + await placesApi.importGpx(1, file) + expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/gpx') + const bare = post.mock.calls[0][1] as FormData + expect(bare.get('importWaypoints')).toBeNull() + expect(bare.get('importRoutes')).toBeNull() + expect(bare.get('importTracks')).toBeNull() + + await placesApi.importGpx(1, file, { waypoints: true, routes: false, tracks: true }) + const flagged = post.mock.calls[1][1] as FormData + expect(flagged.get('importWaypoints')).toBe('true') + expect(flagged.get('importRoutes')).toBe('false') + expect(flagged.get('importTracks')).toBe('true') + }) + + it('FE-APISURF-050: placesApi.importMapFile appends the point/path flags', async () => { + const post = spyPost() + const file = new File(['{}'], 'map.kml') + + await placesApi.importMapFile(1, file) + expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/map') + expect((post.mock.calls[0][1] as FormData).get('importPoints')).toBeNull() + + await placesApi.importMapFile(1, file, { points: true, paths: false }) + const flagged = post.mock.calls[1][1] as FormData + expect(flagged.get('importPoints')).toBe('true') + expect(flagged.get('importPaths')).toBe('false') + }) + + it('FE-APISURF-051: booking import posts every file plus the extraction mode', async () => { + const post = spyPost() + const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] + + await reservationsApi.importBookingPreview(1, files, 'force-ai') + expect(post.mock.calls[0][0]).toBe('/trips/1/reservations/import/booking') + const preview = post.mock.calls[0][1] as FormData + expect(preview.getAll('files')).toHaveLength(2) + expect(preview.get('mode')).toBe('force-ai') + + await reservationsApi.importBookingAsync(1, files) + expect(post.mock.calls[1][0]).toBe('/trips/1/reservations/import/booking/async') + expect((post.mock.calls[1][1] as FormData).get('mode')).toBe('no-ai') + }) + + it('FE-APISURF-052: adminApi.pluginUpload and backupApi.uploadRestore name their form fields', async () => { + const post = spyPost() + + await adminApi.pluginUpload(new File(['zip'], 'plugin.zip')) + expect(post.mock.calls[0][0]).toBe('/admin/plugins/upload') + expect(((post.mock.calls[0][1] as FormData).get('file') as File).name).toBe('plugin.zip') + + await backupApi.uploadRestore(new File(['zip'], 'backup.zip')) + expect(post.mock.calls[1][0]).toBe('/backup/upload-restore') + expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip') + }) +}) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index d2f716143..09e90df47 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -1,5 +1,7 @@ import axios, { AxiosInstance } from 'axios' import type { z } from 'zod' +import type { Place } from '../types' +import { randomId } from '../utils/randomId' import { weatherResultSchema, type WeatherResult, inAppListResultSchema, type InAppListResult, @@ -7,9 +9,11 @@ import { channelTestResultSchema, mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema, mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema, + mapsPlaceEnrichmentResultSchema, type NotificationRespondRequest, type SettingUpsertRequest, type SettingsBulkRequest, - type JourneyCreateRequest, type JourneyAddTripRequest, + type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse, + type JourneyStats, type BookRecord, type BookSaveRequest, type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest, type JourneyShareLinkRequest, type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, @@ -18,7 +22,7 @@ import { type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, - type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, + type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse, type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest, type PlaceCreateRequest, type PlaceUpdateRequest, type ReservationCreateRequest, type ReservationUpdateRequest, @@ -26,7 +30,7 @@ import { type BudgetCreateItemRequest, type BudgetUpdateItemRequest, type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest, type TodoCreateItemRequest, type TodoUpdateItemRequest, - type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, + type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, type PlaceBulkDeleteRequest, type PlaceBulkUpdateRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest, @@ -44,9 +48,15 @@ import { type BookingImportPreviewResponse, type BookingImportConfirmResponse, type BookingImportMode, + type StorageAdminState, + type StorageBackend, + type StorageConfigPut, + type StorageTestResponse, + type StorageUsage, } from '@trek/shared' import { getSocketId } from './websocket' import { probeNow } from '../sync/connectivity' +import { downloadBlob } from '../utils/fileDownload' /** * Validate a response payload against its @trek/shared Zod schema — but only in @@ -104,6 +114,9 @@ const RATE_LIMIT_MESSAGES: Record = { ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.', uk: 'Занадто багато спроб. Спробуйте пізніше.', sv: 'För många försök. Prova igen senare.', + ca: 'Massa intents. Torneu-ho a provar més tard.', + gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.', + vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.', } function translateRateLimit(): string { @@ -139,10 +152,7 @@ apiClient.interceptors.request.use( // The mutation queue sets its own pre-generated key; skip if already set. const method = (config.method ?? '').toLowerCase() if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) { - const key = typeof crypto !== 'undefined' && crypto.randomUUID - ? crypto.randomUUID() - : Math.random().toString(36).slice(2) - config.headers['X-Idempotency-Key'] = key + config.headers['X-Idempotency-Key'] = randomId() } return config }, @@ -227,9 +237,11 @@ apiClient.interceptors.response.use( } if (error.response?.status === 429) { const translated = translateRateLimit() - const data = error.response.data as { error?: string } | undefined - if (data && typeof data === 'object') { - data.error = translated + const data = error.response.data + // Only a plain object body carries an `error` field worth overwriting; + // an array (a validation-error list) or a string is replaced outright. + if (data && typeof data === 'object' && !Array.isArray(data)) { + (data as { error?: string }).error = translated } else { error.response.data = { error: translated } } @@ -366,6 +378,9 @@ export const tripsApi = { list: (params?: Record) => apiClient.get('/trips', { params }).then(r => r.data), create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data), get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data), + // The startup redirect's one lookup — deliberately not list(), which would pull + // every trip with its counts just to read one id. + active: (): Promise => apiClient.get('/trips/active').then(r => r.data), update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data), delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data), uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData), @@ -387,6 +402,8 @@ export const daysApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), + // Whole-day default route mode (#1281); per-segment leg modes override it. + updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data), delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data), } @@ -398,6 +415,15 @@ export const placesApi = { update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data), searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data), + uploadImage: (tripId: number | string, id: number | string, file: File) => { + const fd = new FormData() + fd.append('image', file) + return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd) + }, + rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> => + rating === null + ? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data) + : apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data), importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => { const fd = new FormData() fd.append('file', file) @@ -433,6 +459,10 @@ export const assignmentsApi = { getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + // Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default). + // direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case + // and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape. + updateTransport: (tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing') => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, (direction === 'incoming' ? { transport_mode: mode, direction } : { transport_mode: mode }) satisfies Partial).then(r => r.data), } export const packingApi = { @@ -501,7 +531,12 @@ export const adminApi = { apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), - pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), + // `version` pins the exact version to install (the rollback path); omitted, the server + // resolves the newest TREK-compatible version itself. + pluginUpdate: (id: string, version?: string) => + apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data), + // Release a per-plugin update hold (set by a deliberate non-latest install). + pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data), // Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the // full key the admin was shown (not a fingerprint): the server compares it exactly, so // it can refuse if the registry entry was re-keyed again since the dialog rendered. @@ -537,24 +572,35 @@ export const adminApi = { headers: { 'content-type': 'application/json' }, body: JSON.stringify({ baseUrl, model }), }) - if (!res.ok || !res.body) { + if (!res.ok) { let msg = `Pull failed (${res.status})` try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ } throw new Error(msg) } + // An accepted request without a stream can't be followed to completion. + if (!res.body) throw new Error('Pull returned no progress stream') const reader = res.body.getReader() const dec = new TextDecoder() let buf = '' - for (;;) { - const { done, value } = await reader.read() - if (done) break - buf += dec.decode(value, { stream: true }) - const lines = buf.split('\n') - buf = lines.pop() ?? '' - for (const line of lines) { - if (!line.trim()) continue - try { onProgress(JSON.parse(line)) } catch { /* skip partial */ } + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + buf += dec.decode(value, { stream: true }) + // split() always yields at least one element; the last one is the + // trailing (possibly partial) line carried into the next chunk. + const lines = buf.split('\n') + buf = lines.pop()! + for (const line of lines) { + if (!line.trim()) continue + // Only the parse is swallowed — a throw from onProgress aborts the pull. + let frame: { status?: string; total?: number; completed?: number; error?: string } + try { frame = JSON.parse(line) } catch { continue } + onProgress(frame) + } } + } finally { + reader.cancel().catch(() => {}) } }, checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data), @@ -566,6 +612,8 @@ export const adminApi = { updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data), getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data), updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data), + getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data), + updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data), getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data), updateCollabFeatures: (features: Record) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), @@ -598,6 +646,26 @@ export const adminApi = { updateNotificationPreferences: (prefs: Record>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data), getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data), updateDefaultUserSettings: (settings: Record) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data), + getStorage: (): Promise => apiClient.get('/admin/storage').then(r => r.data), + updateStorage: (config: StorageConfigPut): Promise => + apiClient.put('/admin/storage', config).then(r => r.data), + // A probe is bounded by the target driver's own timeout (default 30s, ×2 with + // one retry) and a mirror probes its targets sequentially — the instance-wide + // 8s axios timeout would abort a legitimate slow probe, so this call carries + // its own generous ceiling. + testStorageBackend: (backend: StorageBackend): Promise => + apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data), + startStorageBackfill: (backend: string): Promise<{ started: true }> => + apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data), + cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> => + apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data), + startStorageMigration: (category: string, to: string): Promise<{ started: true }> => + apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data), + cancelStorageMigration: (category: string): Promise<{ cancelled: true }> => + apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data), + // A full scan of a large install can exceed the 8s instance timeout. + refreshStorageStats: (): Promise => + apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data), } export const addonsApi = { @@ -624,6 +692,74 @@ export interface PluginMapMarker { tone: 'default' | 'success' | 'warn' | 'danger' } +/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized: + * coordinates range-checked, vertex budget capped, styling clamped to the tone + * palette + bounded numerics — never free-form CSS or markup. */ +export interface PluginMapLayerFeature { + type: 'polyline' | 'polygon' | 'circle'; + points?: Array<[number, number]>; + center?: [number, number]; + radiusM?: number; + tone: 'default' | 'success' | 'warn' | 'danger'; + width: number; + dash: 'solid' | 'dash' | 'dot'; + opacity: number; + fill: boolean; + label?: string; +} + +/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */ +export interface PluginMapLayer { + pluginId: string; id: string; name?: string; + features: PluginMapLayerFeature[]; +} + +/** A time contribution a dayScheduleProvider plugin attaches to the day plan + * ("35 min charging at this stop"). Server-normalized: dayIds checked against + * the trip, minutes clamped to a day, labels sanitized + capped. */ +export interface PluginDayScheduleItem { + pluginId: string; id: string; dayId: number; + assignmentId?: number; reservationId?: number; + position?: 'start' | 'end'; + minutes?: number; label: string; + tone: 'default' | 'success' | 'warn' | 'danger'; +} + +/** The colours a dayTintProvider plugin puts into one day card, so leg membership is + * visible while scrolling the itinerary. The card has three separately tintable + * regions; an absent one is not tinted and renders exactly as it does with no plugin. + * Server-normalized: dayIds checked against the trip, one contribution per day (first + * granted provider wins), the `tone` / `color` shorthands already resolved into the + * regions, labels sanitized + capped. + * + * A region carries EITHER a tone from the fixed palette or the plugin's own colour, + * never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing + * else survives normalization, because it lands inside a CSS value); the client still + * owns how strongly it renders — alpha per theme and per region, lightness clamped + * into a readable band. */ +export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger' +export interface PluginDayTint { + pluginId: string; dayId: number; + badgeTone?: PluginDayTintTone; + badgeColor?: string; + headerTone?: PluginDayTintTone; + headerColor?: string; + activityTone?: PluginDayTintTone; + activityColor?: string; + label?: string; +} + +/** A route computed by a routeProvider plugin (server-normalized: coordinates + * range-checked, legs forced to waypoints-1, vias capped). null = provider failed + * or refused — the caller falls back to straight lines like on an OSRM outage. */ +export interface PluginRouteResult { + pluginId: string; profile: string; + coordinates: Array<[number, number]>; + distance: number; duration: number; + legs: Array<{ distance: number; duration: number; note?: string }>; + viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>; +} + /** A text-only section a pdfSectionProvider plugin appends to the trip PDF export. * Server-normalized: counts + lengths are capped, cells are plain strings. */ export interface PluginPdfSection { @@ -667,6 +803,23 @@ export const pluginsApi = { // (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers). mapMarkers: (tripId: number | string) => apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }), + // Vector overlays (polylines/polygons/circles) plugins draw on the trip map via + // the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe. + mapLayers: (tripId: number | string) => + apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }), + // Route the given waypoints through ONE routeProvider plugin profile (targeted, + // not a fan-out — the user picked this profile in the route toggle). Slow by + // design (external solvers): the server allows the plugin 20 s. + pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) => + apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }), + // Time contributions plugins attach to the day plan via the dayScheduleProvider + // hook (charging stops, security buffers). Host-normalized; fail-safe. + daySchedule: (tripId: number | string) => + apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }), + // Per-day colours plugins put behind the day cards via the dayTintProvider hook + // (which leg of the trip a day belongs to). Host-normalized; fail-safe. + dayTints: (tripId: number | string) => + apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }), // Text-only sections plugins append to the trip PDF export via the // pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe. pdfSections: (tripId: number | string) => @@ -764,6 +917,22 @@ export const journeyApi = { // Entries listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data), + // GPX tracks of the trips this journey's entries came from (#1260). + listTracks: (id: number): Promise => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data), + // What the journey adds up to — distance, days, countries, the route (#1973). + // Read by TREK Studio, which freezes the answer into the book document. + stats: (id: number): Promise => apiClient.get(`/journeys/${id}/stats`).then(r => r.data), + + // The Studio book (#1973). `book` is null for a journey that has none yet — + // Studio lays one out and the first save creates it. + getBook: (id: number): Promise<{ book: BookRecord | null }> => + apiClient.get(`/journeys/${id}/book`).then(r => r.data), + // A 409 carries the current record in its body, so a conflict can be shown + // rather than only announced. See useBookStore. + saveBook: (id: number, body: BookSaveRequest): Promise => + apiClient.put(`/journeys/${id}/book`, body).then(r => r.data), + deleteBook: (id: number): Promise => + apiClient.delete(`/journeys/${id}/book`).then(r => r.data), createEntry: (id: number, data: Record) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data), updateEntry: (entryId: number, data: Record) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data), deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data), @@ -803,19 +972,58 @@ export const journeyApi = { getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data), } +// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories. +// The provider sits on the user's own hardware and the server proxies through to +// it, so the 8s default does not apply — and neither does any number picked +// here. The server already holds the deadline, and its worst case is longer than +// anything that would look reasonable in this file: a Synology call is 30s, and +// an expired session makes it re-authenticate and try again. A client cap below +// that turns a slow NAS into a NAS that reads as disconnected, which is what +// these calls looked like before they moved off raw fetch (which had no timeout +// either). Callers that need to give up early pass an AbortSignal. +const MEMORIES_TIMEOUT = 0 + +export const memoriesApi = { + status: (provider: string): Promise<{ connected: boolean }> => + apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), + search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) => + apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data), + albums: (provider: string) => + apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), + albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) => + apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, { + timeout: MEMORIES_TIMEOUT, + params: passphrase ? { passphrase } : undefined, + signal, + }).then(r => r.data), +} + export const mapsApi = { search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')), - autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) => - apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), - details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), + autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) => + apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')), + details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')), + // Pictures and a description for a place that is being looked at but not yet + // saved. Fans out to several providers server-side, so it takes a signal and + // the caller is expected to abort it when the selection changes, and a longer + // timeout than the global 8s — a cold Wikimedia connection alone can eat that. + placeEnrichment: ( + body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record }, + signal?: AbortSignal, + ) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 }) + .then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')), + // Author + licence for a picture already stored in the photo cache, keyed by + // the cache key embedded in its proxy URL. Local read, no provider call. + placePhotoCredit: (key: string) => + apiClient.get(`/maps/enrichment/credit/${encodeURIComponent(key)}`).then(r => r.data as { credit: string | null }), placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')), reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')), resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')), // OSM-only POI explore: places of a category within the current map viewport bbox. // Overpass can be slow on a fresh (uncached) area, so this call gets a longer // timeout than the global default instead of aborting at 8s and showing nothing. - pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) => - apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }), + pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) => + apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }), } export const airportsApi = { @@ -860,6 +1068,8 @@ export const reservationsApi = { create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data), update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data), delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data), + // Assign trip members / named guests to a booking (#1517). + setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data), updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data), importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise => { const fd = new FormData() @@ -889,7 +1099,9 @@ export const healthApi = { } export const weatherApi = { - get: (lat: number, lng: number, date: string): Promise => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + // `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614). + get: (lat: number, lng: number, date: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + getCurrent: (lat: number, lng: number, lang?: string): Promise => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } @@ -962,13 +1174,7 @@ export const backupApi = { credentials: 'include', }) if (!res.ok) throw new Error('Download failed') - const blob = await res.blob() - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = filename - a.click() - URL.revokeObjectURL(url) + downloadBlob(await res.blob(), filename) }, delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data), restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data), diff --git a/client/src/api/collections.test.ts b/client/src/api/collections.test.ts new file mode 100644 index 000000000..8bb4875e4 --- /dev/null +++ b/client/src/api/collections.test.ts @@ -0,0 +1,342 @@ +// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032 +// +// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a +// request-body shape that the server contract depends on. These tests drive each method +// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`. +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse, type JsonBodyType } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { collectionsApi } from './collections' +import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared' + +const BASE = '/api/addons/collections' + +const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true } +const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' } +const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' } + +let requestUrl = '' +let requestBody: unknown + +beforeEach(() => { + requestUrl = '' + requestBody = undefined +}) + +/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */ +function record(data: T) { + return async ({ request }: { request: Request }) => { + requestUrl = request.url + const text = await request.text() + if (text) { + try { + requestBody = JSON.parse(text) + } catch { + requestBody = text + } + } + return HttpResponse.json(data) + } +} + +describe('collectionsApi', () => { + it('FE-API-COLLECTIONS-001: list() unwraps the collections + incomingInvites envelope', async () => { + server.use(http.get(BASE, record({ collections: [collection], incomingInvites: [] }))) + + const res = await collectionsApi.list() + + expect(res.collections).toEqual([collection]) + expect(res.incomingInvites).toEqual([]) + }) + + it('FE-API-COLLECTIONS-002: get() requests the list by id', async () => { + server.use(http.get(`${BASE}/:id`, record({ collection, places: [place] }))) + + const res = await collectionsApi.get(1) + + expect(requestUrl).toContain(`${BASE}/1`) + expect(res.places).toEqual([place]) + expect(res.collection.name).toBe('Tokyo') + }) + + it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => { + server.use(http.post(BASE, record({ collection }))) + + const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' }) + + expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' }) + expect(res.collection.id).toBe(1) + }) + + it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => { + server.use(http.patch(`${BASE}/:id`, record({ collection }))) + + const res = await collectionsApi.update(1, { name: 'Tokyo 2026' }) + + expect(requestUrl).toContain(`${BASE}/1`) + expect(requestBody).toEqual({ name: 'Tokyo 2026' }) + expect(res.collection).toEqual(collection) + }) + + it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => { + server.use(http.post(`${BASE}/:id/cover`, record(collection))) + + const fd = new FormData() + fd.append('cover', new File(['x'], 'cover.jpg')) + const res = await collectionsApi.uploadCover(1, fd) + + expect(requestUrl).toContain(`${BASE}/1/cover`) + expect(res).toEqual(collection) + }) + + it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => { + server.use(http.delete(`${BASE}/:id`, record({ success: true }))) + + const res = await collectionsApi.remove(4) + + expect(requestUrl).toContain(`${BASE}/4`) + expect(res).toEqual({ success: true }) + }) + + it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => { + server.use(http.post(`${BASE}/reorder`, record({ success: true }))) + + await collectionsApi.reorder([3, 1, 2]) + + expect(requestBody).toEqual({ orderedIds: [3, 1, 2] }) + }) + + it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => { + server.use(http.post(`${BASE}/places`, record({ place }))) + + const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true }) + + expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true }) + expect(res.place).toEqual(place) + }) + + it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => { + server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } }))) + + const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 }) + + expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 }) + expect(res.duplicate).toBe(true) + }) + + it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => { + server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] }))) + + const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true) + + expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true }) + expect(res.copied).toBe(2) + }) + + it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => { + server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' }))) + + const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' }) + + expect(requestUrl).toContain(`${BASE}/places/10`) + expect(requestBody).toEqual({ notes: 'busy at night' }) + expect(res.notes).toBe('busy at night') + }) + + it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => { + server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' }))) + + const fd = new FormData() + fd.append('image', new File(['x'], 'p.jpg')) + const res = await collectionsApi.uploadPlaceImage(10, fd) + + expect(requestUrl).toContain(`${BASE}/places/10/image`) + expect(res.image_url).toBe('/uploads/p.jpg') + }) + + it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => { + server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' }))) + + const res = await collectionsApi.setStatus(10, 'visited') + + expect(requestUrl).toContain(`${BASE}/places/10/status`) + expect(requestBody).toEqual({ status: 'visited' }) + expect(res.status).toBe('visited') + }) + + it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => { + server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 }))) + + const res = await collectionsApi.ratePlace(10, 4) + + expect(requestUrl).toContain(`${BASE}/places/10/rating`) + expect(requestBody).toEqual({ rating: 4 }) + expect(res.rating_avg).toBe(4) + }) + + it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => { + let deleted = false + server.use( + http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })), + http.delete(`${BASE}/places/:pid/rating`, () => { + deleted = true + return HttpResponse.json({ ...place, rating_avg: null }) + }), + ) + + const res = await collectionsApi.ratePlace(10, null) + + expect(deleted).toBe(true) + expect(res.rating_avg).toBeNull() + }) + + it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => { + server.use(http.delete(`${BASE}/places/:pid`, record({ success: true }))) + + await collectionsApi.deletePlace(10) + + expect(requestUrl).toContain(`${BASE}/places/10`) + }) + + it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => { + server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 }))) + + const res = await collectionsApi.deleteMany([10, 11]) + + expect(requestBody).toEqual({ ids: [10, 11] }) + expect(res).toEqual({ deleted: 2 }) + }) + + it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => { + server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] }))) + + const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] }) + + expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] }) + expect(res.copied).toBe(1) + expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }]) + }) + + it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => { + server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] }))) + + const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 }) + const params = new URL(requestUrl).searchParams + + expect(params.get('google_place_id')).toBe('g1') + expect(params.get('lat')).toBe('35.6') + expect(params.get('lng')).toBe('139.7') + expect(res.saved).toBe(true) + }) + + it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => { + server.use(http.post(`${BASE}/invite`, record({ success: true }))) + + await collectionsApi.invite(1, 5, 'admin') + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' }) + }) + + it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => { + server.use(http.post(`${BASE}/members/role`, record({ success: true }))) + + await collectionsApi.setMemberRole(1, 5, 'viewer') + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' }) + }) + + it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => { + server.use(http.post(`${BASE}/invite/accept`, record({ success: true }))) + + await collectionsApi.acceptInvite(1) + + expect(requestBody).toEqual({ collection_id: 1 }) + }) + + it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => { + server.use(http.post(`${BASE}/invite/decline`, record({ success: true }))) + + await collectionsApi.declineInvite(2) + + expect(requestBody).toEqual({ collection_id: 2 }) + }) + + it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => { + server.use(http.post(`${BASE}/invite/cancel`, record({ success: true }))) + + await collectionsApi.cancelInvite(1, 5) + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5 }) + }) + + it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => { + server.use(http.post(`${BASE}/leave`, record({ success: true }))) + + await collectionsApi.leave(3) + + expect(requestBody).toEqual({ collection_id: 3 }) + }) + + it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => { + server.use(http.post(`${BASE}/members/remove`, record({ success: true }))) + + await collectionsApi.removeMember(1, 9) + + expect(requestBody).toEqual({ collection_id: 1, user_id: 9 }) + }) + + it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => { + server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] }))) + + const res = await collectionsApi.availableUsers(1) + + expect(requestUrl).toContain(`${BASE}/1/available-users`) + expect(res.users).toEqual([{ id: 5, username: 'bob' }]) + }) + + it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => { + server.use(http.post(`${BASE}/labels`, record(label))) + + const res = await collectionsApi.createLabel(1, 'Food', '#ef4444') + + expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' }) + expect(res).toEqual(label) + }) + + it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => { + server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' }))) + + const res = await collectionsApi.updateLabel(3, { name: 'Eats' }) + + expect(requestUrl).toContain(`${BASE}/labels/3`) + expect(requestBody).toEqual({ name: 'Eats' }) + expect(res.name).toBe('Eats') + }) + + it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => { + server.use(http.delete(`${BASE}/labels/:id`, record({ success: true }))) + + await collectionsApi.deleteLabel(3) + + expect(requestUrl).toContain(`${BASE}/labels/3`) + }) + + it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => { + server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 }))) + + const res = await collectionsApi.assignLabels([3], [10, 11]) + + expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] }) + expect(res.changed).toBe(2) + }) + + it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => { + server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 }))) + + const res = await collectionsApi.unassignLabels([3], [10]) + + expect(requestUrl).toContain(`${BASE}/labels/unassign`) + expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] }) + expect(res.changed).toBe(1) + }) +}) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index 7c682bb32..be4b4c085 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -9,6 +9,7 @@ import type { CollectionUpdateRequest, CollectionSavePlaceRequest, CollectionSaveFromTripRequest, + CollectionImportablesResponse, CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, @@ -16,6 +17,7 @@ import type { CollectionInviteActionRequest, CollectionInviteCancelRequest, CollectionStatus, + CollectionSetStatusManyResponse, Collection, CollectionPlace, CollectionLabel, @@ -68,10 +70,22 @@ export const collectionsApi = { ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data), saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> => ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data), + importable: (collectionId: number, tripId: number): Promise => + ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data), updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise => ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data), + uploadPlaceImage: (pid: number, formData: FormData): Promise => + postMultipart(`${base}/places/${pid}/image`, formData), setStatus: (pid: number, status: CollectionStatus): Promise => ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data), + setStatusMany: (ids: number[], status: CollectionStatus): Promise => + ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data), + setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise => + ax.post(`${base}/places/status-from-trip`, { trip_id: tripId, place_ids: placeIds, status }).then((r: AxiosResponse) => r.data), + ratePlace: (pid: number, rating: number | null): Promise => + rating === null + ? ax.delete(`${base}/places/${pid}/rating`).then((r: AxiosResponse) => r.data) + : ax.put(`${base}/places/${pid}/rating`, { rating }).then((r: AxiosResponse) => r.data), deletePlace: (pid: number): Promise => ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data), deleteMany: (ids: number[]): Promise => diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index 9e5b538f6..81712c63f 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -1,12 +1,26 @@ -// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010 +// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011 import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes' +// The consent page mirrors the server's scope list by hand, so read the server +// file and compare. Parsing keeps this a client-only test (no server import). +function readServerScopes(): string[] { + const here = dirname(fileURLToPath(import.meta.url)) + const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8') + const block = src.slice(src.indexOf('export const SCOPES = {')) + const body = block.slice(0, block.indexOf('} as const;')) + return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1]) +} + describe('SCOPE_GROUPS', () => { it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => { const expected = [ 'trips:read', 'trips:write', 'trips:delete', 'trips:share', 'places:read', 'places:write', + 'collections:read', 'collections:write', 'atlas:read', 'atlas:write', 'packing:read', 'packing:write', 'todos:read', 'todos:write', @@ -16,6 +30,7 @@ describe('SCOPE_GROUPS', () => { 'notifications:read', 'notifications:write', 'vacay:read', 'vacay:write', 'geo:read', 'weather:read', + 'journey:read', 'journey:write', 'journey:share', ] for (const scope of expected) { expect(SCOPE_GROUPS).toHaveProperty(scope) @@ -32,8 +47,16 @@ describe('SCOPE_GROUPS', () => { }) describe('ALL_SCOPES', () => { - it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => { - expect(ALL_SCOPES).toHaveLength(27) + it('FE-OAUTH-SCOPES-003: contains exactly as many scopes as the server defines', () => { + expect(ALL_SCOPES).toHaveLength(readServerScopes().length) + }) + + it('FE-OAUTH-SCOPES-011: matches server/src/mcp/scopes.ts exactly', () => { + const serverScopes = readServerScopes() + // Guards the parse itself: a moved file or a reformatted list must fail + // here rather than quietly compare two empty sets. + expect(serverScopes.length).toBeGreaterThan(0) + expect(new Set(ALL_SCOPES)).toEqual(new Set(serverScopes)) }) it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => { diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts index 22504e1a6..d11bf853e 100644 --- a/client/src/api/oauthScopes.ts +++ b/client/src/api/oauthScopes.ts @@ -20,6 +20,8 @@ export const SCOPE_GROUPS: Record = { 'trips:share': { labelKey: 'oauth.scope.trips:share.label', descriptionKey: 'oauth.scope.trips:share.description', groupKey: 'oauth.scope.group.trips' }, 'places:read': { labelKey: 'oauth.scope.places:read.label', descriptionKey: 'oauth.scope.places:read.description', groupKey: 'oauth.scope.group.places' }, 'places:write': { labelKey: 'oauth.scope.places:write.label', descriptionKey: 'oauth.scope.places:write.description', groupKey: 'oauth.scope.group.places' }, + 'collections:read': { labelKey: 'oauth.scope.collections:read.label', descriptionKey: 'oauth.scope.collections:read.description', groupKey: 'oauth.scope.group.collections' }, + 'collections:write': { labelKey: 'oauth.scope.collections:write.label', descriptionKey: 'oauth.scope.collections:write.description', groupKey: 'oauth.scope.group.collections' }, 'atlas:read': { labelKey: 'oauth.scope.atlas:read.label', descriptionKey: 'oauth.scope.atlas:read.description', groupKey: 'oauth.scope.group.atlas' }, 'atlas:write': { labelKey: 'oauth.scope.atlas:write.label', descriptionKey: 'oauth.scope.atlas:write.description', groupKey: 'oauth.scope.group.atlas' }, 'packing:read': { labelKey: 'oauth.scope.packing:read.label', descriptionKey: 'oauth.scope.packing:read.description', groupKey: 'oauth.scope.group.packing' }, diff --git a/client/src/api/websocket.extra.test.ts b/client/src/api/websocket.extra.test.ts new file mode 100644 index 000000000..3522c6a87 --- /dev/null +++ b/client/src/api/websocket.extra.test.ts @@ -0,0 +1,267 @@ +// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally) +vi.unmock('./websocket') + +// FE-WSCORE-001 to FE-WSCORE-014 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { + connect, disconnect, joinTrip, leaveTrip, getActiveTrips, + setRefetchCallback, setPreReconnectHook, +} from './websocket' + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + static instances: MockWebSocket[] = [] + + readyState: number = MockWebSocket.OPEN + send = vi.fn((_data: string) => {}) + close = vi.fn(() => {}) + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + + constructor(public url: string) { + MockWebSocket.instances.push(this) + } +} + +function lastSocket(): MockWebSocket { + return MockWebSocket.instances[MockWebSocket.instances.length - 1] +} + +const realLocation = window.location + +beforeEach(() => { + vi.useFakeTimers() + MockWebSocket.instances = [] + Object.defineProperty(globalThis, 'WebSocket', { + writable: true, configurable: true, value: MockWebSocket, + }) + server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' }))) +}) + +afterEach(() => { + disconnect() + setRefetchCallback(null) + setPreReconnectHook(null) + vi.useRealTimers() + vi.restoreAllMocks() + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation }) +}) + +/** connect() + settle the token fetch so a socket exists. */ +async function openSocket(): Promise { + connect() + await vi.advanceTimersByTimeAsync(0) + return lastSocket() +} + +describe('websocket > active trips', () => { + it('FE-WSCORE-001: getActiveTrips lists the joined trips as strings', async () => { + expect(getActiveTrips()).toEqual([]) + + joinTrip(42) + joinTrip('7') + expect(getActiveTrips()).toEqual(['42', '7']) + + disconnect() + expect(getActiveTrips()).toEqual([]) + }) + + it('FE-WSCORE-013: join/leave still bookkeep while no socket is open', () => { + joinTrip(5) + expect(getActiveTrips()).toEqual(['5']) + + leaveTrip(5) + expect(getActiveTrips()).toEqual([]) + }) + + it('FE-WSCORE-014: a trip joined before onopen is not re-sent while the socket is closing', async () => { + joinTrip(11) + const sock = await openSocket() + sock.readyState = MockWebSocket.CLOSING + + sock.onopen!() + + expect(sock.send).not.toHaveBeenCalled() + }) +}) + +describe('websocket > reconnect refetch hook', () => { + it('FE-WSCORE-002: the pre-reconnect hook is awaited before the refetch runs', async () => { + const order: string[] = [] + setPreReconnectHook(async () => { order.push('flush') }) + setRefetchCallback(() => { order.push('refetch') }) + + joinTrip(3) + const sock = await openSocket() + sock.onopen!() + await vi.advanceTimersByTimeAsync(0) + + expect(order).toEqual(['flush', 'refetch']) + }) + + it('FE-WSCORE-003: a rejecting pre-reconnect hook still lets the refetch run', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const refetch = vi.fn((_tripId: string) => {}) + setPreReconnectHook(async () => { throw new Error('queue flush failed') }) + setRefetchCallback(refetch) + + joinTrip(3) + const sock = await openSocket() + sock.onopen!() + await vi.advanceTimersByTimeAsync(0) + + expect(refetch).toHaveBeenCalledWith('3') + expect(consoleError).toHaveBeenCalled() + }) + + it('FE-WSCORE-004: a throwing refetch callback is logged, not propagated', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + setRefetchCallback(() => { throw new Error('store blew up') }) + + joinTrip(3) + const sock = await openSocket() + + expect(() => sock.onopen!()).not.toThrow() + expect(consoleError).toHaveBeenCalledWith( + 'Failed to refetch trip data on reconnect:', + expect.any(Error), + ) + }) + + it('FE-WSCORE-005: with no joined trips onopen sends nothing and skips the refetch', async () => { + const refetch = vi.fn((_tripId: string) => {}) + setRefetchCallback(refetch) + + const sock = await openSocket() + sock.onopen!() + + expect(sock.send).not.toHaveBeenCalled() + expect(refetch).not.toHaveBeenCalled() + }) +}) + +describe('websocket > connection lifecycle', () => { + it('FE-WSCORE-006: connect() is a no-op while a socket is still CONNECTING', async () => { + const sock = await openSocket() + sock.readyState = MockWebSocket.CONNECTING + + connect() + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-007: connect() cancels a pending reconnect timer', async () => { + server.use(http.post('/api/auth/ws-token', () => new HttpResponse(null, { status: 503 }))) + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + + // A retry is now armed; connect() must clear it and dial immediately. + server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'fresh' }))) + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(1) + + // The cancelled timer must not fire a second dial afterwards. + await vi.advanceTimersByTimeAsync(5000) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-008: a duplicate close does not stack a second timer or skip a backoff step', async () => { + const sock = await openSocket() + + // Every further token fetch fails, so each retry attempt is countable. + let attempts = 0 + server.use(http.post('/api/auth/ws-token', () => { + attempts++ + return new HttpResponse(null, { status: 503 }) + })) + + // A browser can deliver close twice (after onerror); the second must be ignored. + sock.onclose!() + sock.onclose!() + + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + expect(attempts, 'first retry fires after the 1s delay').toBe(1) + + // Backoff advanced once (1s → 2s), not twice, so the next retry lands at 2s. + await vi.advanceTimersByTimeAsync(2001) + await vi.advanceTimersByTimeAsync(0) + expect(attempts, 'second retry fires after the doubled 2s delay').toBe(2) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-009: a failing ws-token fetch schedules a retry instead of throwing', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline')) + + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ token: 'back-online' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + ) + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastSocket().url).toContain('token=back-online') + }) + + it('FE-WSCORE-010: the socket URL uses ws:// on http and wss:// on https', async () => { + const httpSock = await openSocket() + expect(httpSock.url.startsWith('ws://')).toBe(true) + + disconnect() + MockWebSocket.instances = [] + Object.defineProperty(window, 'location', { + writable: true, configurable: true, + value: { + protocol: 'https:', + host: 'trip.example', + origin: 'https://trip.example', + href: 'https://trip.example/dashboard', + pathname: '/dashboard', + }, + }) + + const secure = await openSocket() + expect(secure.url).toBe('wss://trip.example/ws?token=ws-tok') + }) + + it('FE-WSCORE-011: disconnect() detaches onclose so no reconnect is armed', async () => { + const sock = await openSocket() + disconnect() + + expect(sock.onclose).toBeNull() + expect(sock.close).toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5000) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-012: onerror is inert — the reconnect is driven by onclose', async () => { + const sock = await openSocket() + + expect(() => sock.onerror!()).not.toThrow() + expect(MockWebSocket.instances).toHaveLength(1) + + sock.onclose!() + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(2) + }) +}) diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 00a1eddfa..0f5974648 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -9,6 +9,15 @@ let reconnectDelay = 1000 const MAX_RECONNECT_DELAY = 30000 const listeners = new Set() const activeTrips = new Set() + +/** + * Studio books this tab has open, so presence survives a reconnect. + * + * Same reasoning as `activeTrips`: the server forgets the room when the socket + * dies, and without rejoining, everyone else keeps showing a pointer for + * somebody who is no longer there. + */ +const activeBooks = new Set() let shouldReconnect = false let refetchCallback: RefetchCallback | null = null let mySocketId: string | null = null @@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise { socket.onopen = () => { reconnectDelay = 1000 + activeBooks.forEach(journeyId => { + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:join', journeyId })) + } + }) if (activeTrips.size > 0) { activeTrips.forEach(tripId => { if (socket && socket.readyState === WebSocket.OPEN) { @@ -166,6 +180,7 @@ export function disconnect(): void { reconnectTimer = null } activeTrips.clear() + activeBooks.clear() if (socket) { socket.onclose = null socket.close() @@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void { } } +/** + * Open a Studio book — presence and pointers, for as long as the editor is up. + */ +export function joinBook(journeyId: number | string): void { + activeBooks.add(String(journeyId)) + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) })) + } +} + +export function leaveBook(journeyId: number | string): void { + activeBooks.delete(String(journeyId)) + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) })) + } +} + +/** + * Where this tab's pointer is, in millimetres on the spread. + * + * Dropped silently when the socket is not open. A pointer is worth nothing a + * moment later, so there is nothing to queue and nothing to retry — the next + * one is along in a tenth of a second. + */ +export function sendBookCursor( + journeyId: number | string, + spreadIndex: number, + x: number | null, + y: number | null, +): void { + if (!socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y })) +} + export function addListener(fn: WebSocketListener): void { listeners.add(fn) } diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts new file mode 100644 index 000000000..f89b6dec6 --- /dev/null +++ b/client/src/api/wsEventPolicy.ts @@ -0,0 +1,104 @@ +import type { TrekWsEventName } from '@trek/shared' + +/** + * Client-side handling policy for every event in the shared WS registry + * (`TREK_WS_EVENTS` in @trek/shared). Together with the tripStore lookups + * (DEXIE_WRITERS / STATE_APPLIERS in store/slices/remoteEventHandler.ts), + * these lists partition the registry exactly — the registry-parity test + * fails if a registry event is missing from all of them, or listed twice. + * A new server event therefore forces an explicit client decision (handle + * it, or add it here) instead of being dropped by a silent `default:`. + */ + +/** + * Events consumed by dedicated listeners outside the tripStore reducer. + * Every entry names real handling code — if that code is removed, remove + * the entry (the event then needs a new home or an IGNORED_WS_EVENTS slot). + */ +export const HANDLED_OUTSIDE_TRIP_STORE = [ + // Collab — Collab/MCollab components + useTripWebSocket's collabFileSync + 'collab:note:created', + 'collab:note:updated', + 'collab:note:deleted', + 'collab:poll:created', + 'collab:poll:voted', + 'collab:poll:closed', + 'collab:poll:deleted', + 'collab:message:created', + 'collab:message:reacted', + 'collab:message:deleted', + // In-app notifications — hooks/useInAppNotificationListener + 'notification:new', + 'notification:updated', + // Collections — pages/collections/useCollections ('collections:' prefix listener) + 'collections:updated', + 'collections:accepted', + 'collections:declined', + 'collections:left', + 'collections:deleted', + 'collections:cancelled', + 'collections:removed', + 'collections:invite', + // Vacay — pages/vacay/useVacay + 'vacay:update', + 'vacay:settings', + 'vacay:accepted', + 'vacay:declined', + 'vacay:cancelled', + 'vacay:dissolved', + 'vacay:invite', + 'vacay:share', + 'vacay:share-removed', + 'vacay:shared-update', + // Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener) + 'journey:trip:synced', + 'journey:entry:created', + 'journey:entry:updated', + 'journey:entry:deleted', + 'journey:entries:reordered', + 'journey:contributor:changed', + // Studio book — components/Studio/useBookStore (its own listener: a client + // with nothing outstanding takes the new version, one with unsaved edits + // deliberately does not and conflicts on its next save instead) + 'journey:book:saved', + // Studio presence — components/Studio/useBookPresence (its own listener: + // who has the book open, and where their pointers are) + 'journey:book:peers', + 'journey:book:cursor', + // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) + 'import:progress', + 'import:done', + 'import:error', +] as const satisfies readonly TrekWsEventName[] + +/** + * Events the client deliberately does not act on today (state of the world + * when the registry landed — every one of these was already dropped by the + * old silent `default:` branches). Removing an entry means the event is now + * handled somewhere; ADDING an entry is a product decision that a new server + * event should have no client reaction — never add one just to silence the + * registry-parity test. + */ +export const IGNORED_WS_EVENTS = [ + 'assignment:participants', + 'packing:reordered', + 'packing:bag-created', + 'packing:bag-updated', + 'packing:bag-deleted', + 'packing:bag-members-updated', + 'packing:assignees', + 'packing:template-applied', + 'todo:assignees', + 'budget:settlement-created', + 'budget:settlement-updated', + 'budget:settlement-deleted', + 'reservation:positions', + // Accommodations live in page-local planner state; the client refetches + // them off trip:updated date changes, never off these events. + 'accommodation:created', + 'accommodation:updated', + 'accommodation:deleted', + 'trip:deleted', + 'member:added', + 'member:removed', +] as const satisfies readonly TrekWsEventName[] diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 206f063d5..14afcc882 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,7 +1,7 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011 +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 import { render, screen, waitFor, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; -import { http, HttpResponse } from 'msw'; +import { delay, http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; import { resetAllStores, seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; @@ -21,6 +21,45 @@ function buildAddon(overrides = {}) { }; } +function addonsRoute(addons: ReturnType[]) { + return http.get('/api/admin/addons', () => HttpResponse.json({ addons })); +} + +function llmAddon(config: Record = {}) { + return buildAddon({ + id: 'llm_parsing', + name: 'AI Parsing', + description: 'Extract bookings from files', + icon: 'Sparkles', + type: 'integration', + enabled: true, + config, + }); +} + +function modelsRoute(names: string[], seen?: (string | null)[]) { + return http.get('/api/admin/llm/local/models', ({ request }) => { + seen?.push(new URL(request.url).searchParams.get('baseUrl')); + return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) }); + }); +} + +/** The pill toggle of a top-level addon row. */ +function addonToggle(name: string): HTMLElement { + const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement; + return within(row).getByRole('button'); +} + +/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */ +function subToggle(label: string): HTMLElement { + const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement; + return within(row).getByRole('button'); +} + +function isOn(toggle: HTMLElement): boolean { + return toggle.style.background === 'var(--text-primary)'; +} + beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -59,7 +98,7 @@ describe('AddonManager', () => { it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => { render(); - await screen.findByText('No addons available'); + expect(await screen.findByText('No addons available')).toBeInTheDocument(); }); it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => { @@ -229,4 +268,360 @@ describe('AddonManager', () => { expect(() => render()).not.toThrow(); await screen.findByText('Mystery Addon'); }); + + it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => { + server.use(http.get('/api/admin/addons', () => HttpResponse.error())); + render(<>); + + await screen.findByText('Failed to update addon'); + expect(screen.getByText('No addons available')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => { + seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } }); + render(); + + await screen.findByText('No addons available'); + expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'); + }); + + it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }), + buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }), + buildAddon({ id: 'todo', name: 'Todo List' }), + ])); + render(); + + await screen.findByText('Todo List'); + expect(screen.queryByText('Memories')).not.toBeInTheDocument(); + expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), + buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Immich'); + // immich and synologyphotos ship a vendor glyph, unsplash does not + const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement; + expect(immichRow.querySelector('svg')).toBeInTheDocument(); + const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement; + expect(synologyRow.querySelector('svg')).toBeInTheDocument(); + const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement; + expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument(); + + expect(isOn(subToggle('Immich'))).toBe(true); + expect(isOn(subToggle('Unsplash'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }), + ]), + http.put('/api/admin/addons/immich', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(subToggle('Immich')); + + await waitFor(() => expect(body).toEqual({ enabled: true })); + await screen.findByText('Addon updated'); + expect(isOn(subToggle('Immich'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }), + ]), + http.put('/api/admin/addons/unsplash', () => HttpResponse.error()), + ); + render(<>); + await screen.findByText('Unsplash'); + + await user.click(subToggle('Unsplash')); + + await screen.findByText('Failed to update addon'); + await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true)); + }); + + it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + const user = userEvent.setup(); + const onToggleCollabFeature = vi.fn(); + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + await screen.findByText('Chat'); + expect(screen.getByText('Notes')).toBeInTheDocument(); + expect(screen.getByText('Polls')).toBeInTheDocument(); + expect(screen.getByText("What's Next")).toBeInTheDocument(); + expect(isOn(subToggle('Chat'))).toBe(true); + expect(isOn(subToggle('Notes'))).toBe(false); + + await user.click(subToggle('Polls')); + expect(onToggleCollabFeature).toHaveBeenCalledWith('polls'); + }); + + it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => { + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render(); + + await screen.findByText('Collab'); + expect(screen.queryByText('Polls')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { + server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); + render(); + + await screen.findByText('AI Parsing'); + expect(screen.getByText('Extract bookings from files')).toBeInTheDocument(); + expect(screen.queryByText('Connection')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls)); + render(); + + await screen.findByText('Installed on the server'); + await screen.findByRole('button', { name: 'llama3:8b' }); + expect(urls[0]).toBe('http://localhost:11434/v1'); + + await user.click(screen.getByRole('button', { name: 'llama3:8b' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); + + // qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull" + await user.click(screen.getByRole('button', { name: 'Use' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); + }); + + it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ error: 'down' }, { status: 500 }) + : HttpResponse.json({ models: [] }); + }), + ); + render(); + + await screen.findByText(/Request failed with status code 500/); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('No models installed yet — pull one below.'); + expect(calls).toBe(2); + }); + + it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls)); + render(); + + await screen.findByText('Installed on the server'); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + + // A hand-typed base URL is used for the next lookup on blur + await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1'); + await user.tab(); + await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1')); + + await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument(); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + await user.click(screen.getByRole('button', { name: 'Anthropic' })); + expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); + expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => { + const user = userEvent.setup(); + let pulled: unknown = null; + let modelCalls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + modelCalls += 1; + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] }); + }), + http.post('/api/admin/llm/local/pull', async ({ request }) => { + pulled = await request.json(); + await delay(150); + return new HttpResponse( + '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + await screen.findByText('Pulling…'); + expect(screen.getByText('starting…')).toBeInTheDocument(); + + await screen.findByText('Model pulled'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' }); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); + }); + + it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]), + modelsRoute([]), + http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error(); + }), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + await screen.findByText('no disk space'); + expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Saved'); + expect(bodies[0]).toEqual({ + config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true }, + }); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Failed to save'); + }); + + it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + modelsRoute([]), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await screen.findByText('Installed on the server'); + expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b '); + await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live'); + + await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live'); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Saved'); + // The model is trimmed before it is stored, the key is sent verbatim + expect(bodies[0]).toEqual({ + config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false }, + }); + }); + + it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]), + modelsRoute([]), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await screen.findByText('Installed on the server'); + + await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ })); + await user.click(screen.getByRole('button', { name: 'Anthropic' })); + await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001'); + await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live'); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Saved'); + // The stale local base URL must not ride along to Anthropic — it would hijack the endpoint. + expect(bodies[0]).toEqual({ + config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false }, + }); + }); + + it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + modelsRoute([]), + http.post('/api/admin/llm/local/pull', () => new HttpResponse( + '{"status":"pulling manifest"}\n{"error":"manifest not found"}\n', + { headers: { 'Content-Type': 'application/x-ndjson' } }, + )), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + await screen.findByText('manifest not found'); + expect(screen.queryByText('Model pulled')).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled()); + expect(screen.queryByText('Pulling…')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls)); + render(); + + await screen.findByText('Connection'); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1'); + await user.tab(); + + await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument()); + expect(urls).toHaveLength(0); + }); }); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index 1c658ee11..c5ea9e6ce 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, type ComponentType } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' import { useSettingsStore } from '../../store/settingsStore' @@ -27,7 +27,7 @@ function SynologyIcon({ size = 14 }: { size?: number }) { ) } -const PROVIDER_ICONS: Record> = { +const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, } @@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} -
@@ -470,7 +471,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { {installed.length > 0 && (
{installed.map(name => ( -
{installedHere ? ( - ) : ( - )} @@ -526,7 +527,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { )} - @@ -589,7 +590,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} {!hideToggle && ( - )} {expanded && hidden > 0 && ( - @@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() { {new Date(session.created_at).toLocaleDateString(locale)} - - @@ -235,16 +240,21 @@ export default function AdminMcpTokensPanel() { {/* Delete MCP token modal */} {deleteConfirmId !== null && (
{ if (e.target === e.currentTarget) setDeleteConfirmId(null) }}> + role="button" tabIndex={0} aria-label={t('common.close')} + onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }} + onKeyDown={e => { + if (e.target !== e.currentTarget) return + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setDeleteConfirmId(null) } + }}>

{t('admin.mcpTokens.deleteTitle')}

{t('admin.mcpTokens.deleteMessage')}

- - diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 8c1884818..693925b0c 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -1,8 +1,10 @@ import { http, HttpResponse } from 'msw' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import userEvent from '@testing-library/user-event' import { server } from '../../../tests/helpers/msw/server' -import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render' +import { fireEvent, render, screen, waitFor, within } from '../../../tests/helpers/render' import { resetAllStores } from '../../../tests/helpers/store' +import { ToastContainer } from '../shared/Toast' import AdminPluginsPanel from './AdminPluginsPanel' /** @@ -199,6 +201,9 @@ function registryEntry(over: Record = {}) { id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify', type: 'integration', latest: '2.0.0', minTrekVersion: null, reviewedAt: null, screenshotUrl: null, signed: true, authorPublicKey: 'NEWKEYbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + // The real browse response always carries latestCompatible (server-side hostCompat); + // mirror `latest` by default, exactly like a fully-compatible entry. + latestCompatible: (over.latest as string | undefined) ?? '2.0.0', ...over, } } @@ -482,3 +487,1650 @@ describe('AdminPluginsPanel — TREK-version compatibility', () => { expect(await screen.findByText(/needs trek >=3\.2\.0 <4\.0\.0/i)).toBeInTheDocument() }) }) + +/** + * The rest of the panel: load states, the toolbar, sideloading, the row actions behind the + * ⋯ menu, and the dialog each 409 routes to. + */ +function panelWith( + plugins: Record[], + opts: { enabled?: boolean; devLink?: boolean; registry?: Record[] } = {}, +) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ + enabled: opts.enabled ?? true, devLink: opts.devLink ?? false, plugins, + })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(opts.registry ?? [])), + ) +} + +function withToast() { + return render(<>) +} + +/** The Discover tab is a role="tab"; the empty state offers a plain button with the same label. */ +async function clickDiscover() { + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) +} + +/** The panel root, which owns the drag-and-drop handlers. */ +function panelRoot(container: HTMLElement): HTMLElement { + return (container.querySelector('input[type="file"]') as HTMLElement).parentElement as HTMLElement +} + +/** Opens one of the toolbar dropdowns (identified by its current title) and picks an option. */ +async function pickFilter(trigger: RegExp, option: RegExp) { + fireEvent.click(await screen.findByTitle(trigger)) + fireEvent.click(await screen.findByRole('button', { name: option })) +} + +/** The enable toggle of a named installed row. */ +function rowToggle(name: string): HTMLElement { + const row = screen.getByText(name).closest('.group') as HTMLElement + return within(row).getByRole('button', { name: 'Enable plugin' }) +} + +describe('AdminPluginsPanel — load states and toolbar', () => { + it('FE-COMP-PLUGINS-PANEL-001: a failing plugin list shows the load error instead of an empty list', async () => { + server.use(http.get('*/api/admin/plugins', () => HttpResponse.error())) + render() + + expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument() + expect(screen.queryByPlaceholderText('Search plugins…')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-002: a disabled runtime replaces the whole body with the notice', async () => { + panelWith([plugin()], { enabled: false }) + render() + + expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument() + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + expect(screen.queryByText('Runtime on')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-003: with nothing installed the empty state jumps to Discover', async () => { + panelWith([]) + render() + + await screen.findByText('No plugins installed yet.') + expect(screen.getByText('Runtime on')).toBeInTheDocument() + fireEvent.click(screen.getAllByRole('button', { name: /^discover/i }).pop()!) + + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-004: the search box filters the installed list and says when nothing matches', async () => { + panelWith([plugin({ id: 'a', name: 'Gotify' }), plugin({ id: 'b', name: 'Ntfy', description: 'Push too' })]) + render() + await screen.findByText('Gotify') + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'ntfy' } }) + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + expect(screen.getByText('Ntfy')).toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'zzz' } }) + expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-005: the type filter narrows the list and marks itself active', async () => { + panelWith([ + plugin({ id: 'a', name: 'Gotify', type: 'integration' }), + plugin({ id: 'b', name: 'Countdown', type: 'widget' }), + ]) + render() + await screen.findByText('Gotify') + + await pickFilter(/^Type: All types$/, /^Widget$/) + + expect(await screen.findByTitle('Type: Widget')).toBeInTheDocument() + expect(screen.getByText('Countdown')).toBeInTheDocument() + expect(screen.queryByText('Gotify')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-006: the status filter separates off, error and update-available rows', async () => { + panelWith([ + plugin({ id: 'trek-gotify', name: 'OnOne', enabled: 1, status: 'active', version: '1.0.0', source_repo: 'acme/gotify' }), + plugin({ id: 'off-one', name: 'OffOne', enabled: 0, status: 'inactive' }), + plugin({ id: 'bad-one', name: 'BadOne', enabled: 1, status: 'error', last_error: 'boom' }), + ], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + await screen.findByText('OnOne') + + await pickFilter(/^Status: All$/, /^Off$/) + expect(await screen.findByText('OffOne')).toBeInTheDocument() + expect(screen.queryByText('OnOne')).not.toBeInTheDocument() + + await pickFilter(/^Status: Off$/, /^Error$/) + expect(await screen.findByText('BadOne')).toBeInTheDocument() + expect(screen.getByText('boom')).toBeInTheDocument() + + await pickFilter(/^Status: Error$/, /^Update available$/) + expect(await screen.findByText('OnOne')).toBeInTheDocument() + expect(screen.queryByText('BadOne')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-007: an installed-only sort key snaps back to Name on the Discover tab', async () => { + panelWith([plugin()], { registry: [registryEntry()] }) + render() + await screen.findByText('Gotify') + + await pickFilter(/^Sort: Name$/, /^Updates first$/) + expect(await screen.findByTitle('Sort: Updates first')).toBeInTheDocument() + + await clickDiscover() + await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-008: rescan re-pulls the registry past its cache and confirms with a toast', async () => { + const refreshFlags: (string | null)[] = [] + panelWith([plugin()]) + server.use( + http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({ ok: true })), + http.get('*/api/admin/plugins/registry', ({ request }) => { + refreshFlags.push(new URL(request.url).searchParams.get('refresh')) + return HttpResponse.json([registryEntry({ latest: '2.0.0' })]) + }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(screen.getAllByTitle('Rescan')[0]) + + expect(await screen.findByText('Rescanned the plugins folder')).toBeInTheDocument() + expect(refreshFlags).toContain('1') + }) +}) + +describe('AdminPluginsPanel — sideloading', () => { + const zip = () => new File(['zip'], 'plugin.zip', { type: 'application/zip' }) + + it('FE-COMP-PLUGINS-PANEL-009: the toolbar button opens the hidden picker and uploads the pick', async () => { + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ id: 'trek-new' }))) + const { container } = withToast() + await screen.findByText('Gotify') + + const input = container.querySelector('input[type="file"]') as HTMLInputElement + const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {}) + fireEvent.click(screen.getAllByTitle('Upload plugin')[0]) + expect(clickSpy).toHaveBeenCalled() + clickSpy.mockRestore() + + await userEvent.upload(input, zip()) + expect(await screen.findByText(/trek-new.*uploaded/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-010: a rejected upload surfaces the server message', async () => { + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => HttpResponse.json({ error: 'not a plugin archive' }, { status: 400 }))) + const { container } = withToast() + await screen.findByText('Gotify') + + await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, zip()) + + expect(await screen.findByText('not a plugin archive')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-011: dragging a file over the panel offers a drop target and installs it', async () => { + let uploaded = false + panelWith([plugin()]) + server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-dropped' }) })) + const { container } = withToast() + await screen.findByText('Gotify') + + const panel = panelRoot(container) + const dataTransfer = { types: ['Files'], files: [zip()] } + fireEvent.dragEnter(panel, { dataTransfer }) + expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument() + + fireEvent.dragLeave(panel, { dataTransfer }) + await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument()) + + fireEvent.dragEnter(panel, { dataTransfer }) + fireEvent.dragOver(panel, { dataTransfer }) + fireEvent.drop(panel, { dataTransfer }) + + await waitFor(() => expect(uploaded).toBe(true)) + expect(await screen.findByText(/trek-dropped.*uploaded/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-012: a drag that carries no files is ignored', async () => { + panelWith([plugin()]) + const { container } = render() + await screen.findByText('Gotify') + + fireEvent.dragEnter(panelRoot(container), { dataTransfer: { types: ['text/plain'], files: [] } }) + + expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-013: the dev-link form registers a local path and reports failures', async () => { + const paths: string[] = [] + panelWith([plugin()], { devLink: true }) + server.use(http.post('*/api/admin/plugins/link', async ({ request }) => { + const body = await request.json() as { path: string } + paths.push(body.path) + return paths.length === 1 + ? HttpResponse.json({ id: 'trek-local' }) + : HttpResponse.json({ error: 'no manifest there' }, { status: 400 }) + })) + withToast() + const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin') + + expect(screen.getByRole('button', { name: /^link$/i })).toBeDisabled() + + fireEvent.change(input, { target: { value: '/srv/plugins/mine' } }) + fireEvent.click(screen.getByRole('button', { name: /^link$/i })) + expect(await screen.findByText(/linked trek-local/i)).toBeInTheDocument() + expect(paths).toEqual(['/srv/plugins/mine']) + + fireEvent.change(screen.getByPlaceholderText('/absolute/path/to/your/plugin'), { target: { value: '/nope' } }) + fireEvent.click(screen.getByRole('button', { name: /^link$/i })) + expect(await screen.findByText('no manifest there')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — row actions', () => { + async function openRowMenu(p: Record = plugin()) { + panelWith([p]) + withToast() + fireEvent.click(await screen.findByTestId(`plugin-row-menu-btn-${p.id}`)) + } + + /** Every modal in the panel closes by clicking its backdrop. */ + function closeModal() { + fireEvent.click(document.querySelector('.fixed.inset-0.z-50') as HTMLElement) + } + + it('FE-COMP-PLUGINS-PANEL-014: the error log lists what the runtime recorded', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => + HttpResponse.json({ errors: [{ ts: '2026-01-01T00:00:00Z', level: 'error', message: 'connect ECONNREFUSED' }] }))) + await openRowMenu() + + fireEvent.click(screen.getByText('View error log')) + expect(await screen.findByText('connect ECONNREFUSED')).toBeInTheDocument() + expect(screen.getByText('error')).toBeInTheDocument() + + closeModal() + await waitFor(() => expect(screen.queryByText('connect ECONNREFUSED')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-015: an unreachable error log still opens, empty', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.error())) + await openRowMenu() + + fireEvent.click(screen.getByText('View error log')) + + expect(await screen.findByText('No errors logged.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-016: allowed hosts can be added and removed, and a rejected save says why', async () => { + let hosts: string[] = [] + let puts = 0 + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts })), + http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => { + puts += 1 + const body = await request.json() as { hosts: string[] } + if (puts === 3) return HttpResponse.json({ error: 'not a valid host' }, { status: 400 }) + hosts = body.hosts + return HttpResponse.json({ hosts }) + }), + ) + await openRowMenu() + + fireEvent.click(screen.getByText('Allowed hosts')) + expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /^add$/i })).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'gotify.lan' } }) + fireEvent.click(screen.getByRole('button', { name: /^add$/i })) + expect(await screen.findByText('gotify.lan')).toBeInTheDocument() + + fireEvent.click(screen.getAllByLabelText('Delete')[0]) + await waitFor(() => expect(screen.getByText('No hosts added yet.')).toBeInTheDocument()) + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'bad host' } }) + fireEvent.click(screen.getByRole('button', { name: /^add$/i })) + expect(await screen.findByText('not a valid host')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-017: a plugin whose hosts are fixed in its manifest says the list is unsupported', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.error())) + await openRowMenu() + + fireEvent.click(screen.getByText('Allowed hosts')) + + expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument() + expect(screen.queryByPlaceholderText('gotify.example.com')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-018: Delete asks first, then uninstalls with its data', async () => { + let body: unknown = null + server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ ok: true }) + })) + await openRowMenu() + + fireEvent.click(screen.getByText('Delete')) + expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument() + + fireEvent.click(screen.getAllByRole('button', { name: /^delete$/i }).pop()!) + + expect(await screen.findByText('Plugin uninstalled')).toBeInTheDocument() + expect(body).toEqual({ deleteData: true }) + }) + + it('FE-COMP-PLUGINS-PANEL-019: Restart cycles the plugin off and on again', async () => { + const seen: string[] = [] + server.use( + http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { seen.push('deactivate'); return HttpResponse.json({ ok: true }) }), + http.post('*/api/admin/plugins/trek-gotify/activate', () => { seen.push('activate'); return HttpResponse.json({ ok: true }) }), + ) + await openRowMenu(plugin({ enabled: 1, status: 'active' })) + + fireEvent.click(screen.getByText('Restart')) + + expect(await screen.findByText('Plugin restarted')).toBeInTheDocument() + expect(seen).toEqual(['deactivate', 'activate']) + }) + + it('FE-COMP-PLUGINS-PANEL-020: a disabled plugin offers no Restart', async () => { + await openRowMenu(plugin({ enabled: 0, status: 'inactive' })) + + expect(screen.getByText('View error log')).toBeInTheDocument() + expect(screen.queryByText('Restart')).not.toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — enabling a plugin', () => { + it('FE-COMP-PLUGINS-PANEL-021: turning a plugin off deactivates it', async () => { + let called = false + panelWith([plugin({ enabled: 1, status: 'active' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }) })) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('Plugin deactivated')).toBeInTheDocument() + expect(called).toBe(true) + }) + + it('FE-COMP-PLUGINS-PANEL-022: turning one on also reports the dependencies it enabled first', async () => { + panelWith([ + plugin({ enabled: 0, status: 'inactive', dependencies: { pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] } }), + plugin({ id: 'trek-core', name: 'Core', enabled: 0, status: 'inactive' }), + ]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('Plugin activated')).toBeInTheDocument() + expect(await screen.findByText(/enabled required plugin\(s\) first: core/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-023: a disabled required addon is reported as a toast, not a dialog', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'ADDON_DISABLED', addons: ['journey'] }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-024: any other activation failure surfaces the server message', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'DEPENDENCY_CYCLE', error: 'circular dependency' }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('circular dependency')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-025: a missing dependency opens the resolve dialog and one click fixes it', async () => { + let activates = 0 + let installed: unknown = null + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => { + activates += 1 + return activates === 1 + ? HttpResponse.json({ + code: 'DEPENDENCY_MISSING', + missing: [{ id: 'trek-core', version: '^1.0.0' }], + versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }], + }, { status: 409 }) + : HttpResponse.json({ ok: true }) + }), + http.post('*/api/admin/plugins/install', async ({ request }) => { + installed = await request.json() + return HttpResponse.json({ installed: ['trek-core'], requiredAddons: ['journey'] }) + }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + expect(await screen.findByText('Missing dependencies')).toBeInTheDocument() + expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument() + expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument() + expect(screen.getByText(/downloads the latest compatible version/i)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /^download$/i })) + + expect(await screen.findByText('Downloaded trek-core')).toBeInTheDocument() + expect(installed).toEqual({ id: 'trek-core', constraint: '^1.0.0', withDependencies: true }) + expect(await screen.findByText(/enable the required addon\(s\) first: journey/i)).toBeInTheDocument() + await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-026: the resolve dialog can be dismissed without downloading anything', async () => { + let installs = 0 + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })), + http.post('*/api/admin/plugins/install', () => { installs += 1; return HttpResponse.json({}) }), + ) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i })) + + await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) + expect(installs).toBe(0) + }) + + it('FE-COMP-PLUGINS-PANEL-027: widened permissions route to the consent dialog, which can be deferred', async () => { + panelWith([plugin({ enabled: 0, status: 'inactive', source_repo: 'acme/gotify', signed: false })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips', 'weird:perm'], newEgress: ['api.acme.io'] }, { status: 409 }))) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument() + // An unknown permission is still shown — as its raw code + expect(screen.getByText('weird:perm')).toBeInTheDocument() + expect(screen.getByText('api.acme.io')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /keep off for now/i })) + expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-028: approving consent re-activates with the consent flag', async () => { + const bodies: unknown[] = [] + panelWith([plugin({ enabled: 0, status: 'inactive' })]) + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => { + bodies.push(await request.json()) + return bodies.length === 1 + ? HttpResponse.json({ code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 }) + : HttpResponse.json({ ok: true }) + })) + withToast() + await screen.findByText('Gotify') + + fireEvent.click(rowToggle('Gotify')) + fireEvent.click(await screen.findByRole('button', { name: /approve & turn on/i })) + + expect(await screen.findByText('Plugin updated')).toBeInTheDocument() + expect(bodies[1]).toEqual({ consent: true }) + }) +}) + +describe('AdminPluginsPanel — updates', () => { + const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false }) + + it('FE-COMP-PLUGINS-PANEL-029: the update banner updates every outdated plugin at once', async () => { + const updated: string[] = [] + panelWith( + [outdated, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })], + { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }, + ) + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)) + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + })) + withToast() + + expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /update all/i })) + + await waitFor(() => expect([...updated].sort()).toEqual(['trek-gotify', 'trek-ntfy'])) + expect((await screen.findAllByText('Plugin updated')).length).toBeGreaterThan(0) + }) + + it('FE-COMP-PLUGINS-PANEL-030: an update that widens permissions queues the consent dialog', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0', signed: false })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:users'], newEgress: [] }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i })) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText(/read basic profile info/i)).toBeInTheDocument() + // The registry says the new code is unsigned — say so where the rights get widened + expect(screen.getByText(/nothing ties this version to its author/i)).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-031: a failed update that is not a signature refusal is a plain toast', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ error: 'registry unreachable' }, { status: 502 }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /update → v2\.0\.0/i })) + + expect(await screen.findByText('registry unreachable')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-032: a re-trusted update that widens rights still asks for consent', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' }, + })], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: ['api.acme.io'] }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /^review$/i })) + fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i })) + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument() + expect(screen.getByText('api.acme.io')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-033: a failed re-trust surfaces the server message', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDAAAAA…BBBBBBBB', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', version: '2.0.0', detail: 'key rotated' }, + })], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ error: 'key does not match' }, { status: 400 }))) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /^review$/i })) + fireEvent.click(await screen.findByRole('button', { name: /trust the new key/i })) + + expect(await screen.findByText('key does not match')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-034a: a stable release counts as newer than the prerelease of the same version', async () => { + panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0-beta.1', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-034: a block with no recorded version stands while the registry is silent', async () => { + panelWith([plugin({ + source_repo: 'acme/gotify', operatorEgress: false, + updateBlock: { code: 'SIGNATURE_INVALID', version: null, detail: 'digest mismatch' }, + })], { registry: [] }) + render() + + expect(await screen.findByText(/update blocked — digest mismatch/i)).toBeInTheDocument() + }) +}) + +/** + * Update availability must follow what this TREK can actually INSTALL (latestCompatible, + * computed server-side), never the absolute newest published version — a banner counting + * versions the update endpoint would refuse nags the admin toward a guaranteed 400. + */ +describe('AdminPluginsPanel — compatible updates only', () => { + const outdated = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false }) + + it('FE-COMP-PLUGINS-UPD-001: an update this TREK cannot install is neither offered nor counted', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-002: the update offer is the newest compatible version, not the absolute latest', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.getByText('1 updates available for your plugins.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v3\.0\.0/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-003: a newer version needing a newer TREK leaves a passive hint on the row', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-004: a legacy entry with only minTrekVersion still gets a hint range', async () => { + panelWith([outdated], { + registry: [registryEntry({ latest: '2.0.0', latestCompatible: null, trek: null, minTrekVersion: '4.0.0', hostVersion: '3.3.0' })], + }) + render() + + expect(await screen.findByText('v2.0.0 available — needs TREK >=4.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-UPD-005: no hint when the latest version is the one on offer', async () => { + panelWith([outdated], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + + expect(await screen.findByRole('button', { name: /update → v2\.0\.0/i })).toBeInTheDocument() + expect(screen.queryByText(/needs TREK/i)).not.toBeInTheDocument() + }) +}) + +/** + * The update hold (#plugins): a deliberate non-latest install sets updateHold on the row, + * which takes it out of the banner count and "Update all" — a rollback the admin just + * made must not be nagged straight back. The row says so and offers to resume. + */ +describe('AdminPluginsPanel — update hold', () => { + const held = plugin({ source_repo: 'acme/gotify', version: '1.0.0', operatorEgress: false, updateHold: true }) + + it('FE-COMP-PLUGINS-HOLD-001: a held plugin leaves the banner and loses its update button', async () => { + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + render() + await screen.findByText('Gotify') + + expect(screen.queryByText(/updates available for your plugins/i)).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /update → v2\.0\.0/i })).not.toBeInTheDocument() + expect(screen.getByText('Updates paused at v1.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-002: Resume updates releases the hold on the server', async () => { + let resumed = false + panelWith([held], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.post('*/api/admin/plugins/trek-gotify/resume-updates', () => { + resumed = true + return HttpResponse.json({ updateHold: false }) + })) + withToast() + + fireEvent.click(await screen.findByRole('button', { name: /resume updates/i })) + + await waitFor(() => expect(resumed).toBe(true)) + expect(await screen.findByText('Updates resumed')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-HOLD-003: Update all skips held plugins', async () => { + const updated: string[] = [] + panelWith( + [held, plugin({ id: 'trek-ntfy', name: 'Ntfy', source_repo: 'acme/ntfy', version: '1.0.0', operatorEgress: false })], + { registry: [registryEntry({ latest: '2.0.0' }), registryEntry({ id: 'trek-ntfy', latest: '3.0.0' })] }, + ) + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)) + return HttpResponse.json({ version: '3.0.0', activated: true, newPermissions: [], newEgress: [] }) + })) + withToast() + + expect(await screen.findByText('1 updates available for your plugins.')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /update all/i })) + + await waitFor(() => expect(updated).toEqual(['trek-ntfy'])) + }) +}) + +/** + * The version picker (#plugins): install any published version from the Discover detail + * modal, and roll an installed plugin back through the update pipeline (which owns the + * stop-child + re-consent machinery). Compat verdicts are SERVER-computed per version — + * the picker only renders them, it never re-derives range logic. + */ +describe('AdminPluginsPanel — version picker', () => { + const VERSIONS = [ + { version: '3.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false }, + { version: '2.0.0', publishedAt: '2026-07-01', size: 1024, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + { version: '1.5.0', publishedAt: '2026-06-01', size: 1000, signed: true, trek: '>=3.0.0 <4.0.0', compatible: true }, + ] + const detailWithVersions = () => ({ + ...registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false }), + size: 1024, publishedAt: null, manifest: null, versions: VERSIONS, + }) + + function mockDiscoverDetail() { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => + HttpResponse.json([registryEntry({ latest: '3.0.0', latestCompatible: '2.0.0', trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false })])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + ) + } + + it('FE-COMP-PLUGINS-VPICK-001: the detail modal lists every version and installs the picked one', async () => { + let body: unknown = null + mockDiscoverDetail() + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ id: 'trek-gotify', version: '1.5.0' }) + })) + withToast() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })) + + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-002: an incompatible version is greyed with its TREK requirement, not installable', async () => { + mockDiscoverDetail() + render() + const tabs = await screen.findAllByRole('tab', { name: /^discover/i }) + fireEvent.click(tabs[0]) + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText(/^needs TREK >=4\.0\.0$/i)).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /^install 3\.0\.0$/i })).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-003: Change version rolls back through update, after an explicit data warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '1.5.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 1\.5\.0$/i })) + + // The rollback keeps the plugin's data — the older code may not read it. Informed consent, no snapshot. + expect(await screen.findByText(/data written by the newer version stays in place/i)).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /roll back/i })) + + await waitFor(() => expect(body).toEqual({ version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-VPICK-004: picking a NEWER version in the picker updates without the downgrade warning', async () => { + let body: unknown = null + panelWith([plugin({ source_repo: 'acme/gotify', version: '1.5.0', operatorEgress: false })], { + registry: [registryEntry({ latest: '2.0.0' })], + }) + server.use( + http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions())), + http.post('*/api/admin/plugins/trek-gotify/update', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + withToast() + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + fireEvent.click(await screen.findByRole('button', { name: /^switch to 2\.0\.0$/i })) + + await waitFor(() => expect(body).toEqual({ version: '2.0.0' })) + expect(screen.queryByText(/data written by the newer version stays in place/i)).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-VPICK-005: the installed version is marked and not switchable; sideloads get no picker', async () => { + panelWith([ + plugin({ source_repo: 'acme/gotify', version: '2.0.0', operatorEgress: false }), + plugin({ id: 'side-load', name: 'Sideload', source_repo: 'local:upload', version: '1.0.0', operatorEgress: false }), + ], { registry: [registryEntry({ latest: '2.0.0' })] }) + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detailWithVersions()))) + withToast() + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-side-load')) + expect(screen.queryByRole('button', { name: /change version/i })).not.toBeInTheDocument() + fireEvent.click(screen.getByTestId('plugin-row-menu-btn-side-load')) // close + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + fireEvent.click(await screen.findByRole('button', { name: /change version/i })) + + const current = await screen.findByTestId('version-row-2.0.0') + expect(within(current).getByText('Installed')).toBeInTheDocument() + expect(within(current).queryByRole('button', { name: /switch/i })).not.toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — capability and dependency chips', () => { + it('FE-COMP-PLUGINS-PANEL-035: a permission-rich plugin renders one chip per capability', async () => { + panelWith([plugin({ + operatorEgress: false, + permissions: JSON.stringify([ + 'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files', + 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta', + 'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider', + 'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe', 'http:outbound:api.acme.io', + ]), + capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } }), + })]) + render() + await screen.findByText('Gotify') + + for (const label of [ + 'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files', + 'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata', + 'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos', + 'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map', 'Offers routing', + 'Adds plan times', 'Reads your position', 'Reacts to activity', 'api.acme.io', + ]) { + expect(screen.getByText(label)).toBeInTheDocument() + } + }) + + it('FE-COMP-PLUGINS-PANEL-036: read-only costs and a slotted widget each get their own wording', async () => { + panelWith([plugin({ + operatorEgress: false, + permissions: JSON.stringify(['db:read:costs']), + capabilities: JSON.stringify({ widget: { slot: 'place-detail' } }), + })]) + render() + await screen.findByText('Gotify') + + expect(screen.getByText('Reads your costs')).toBeInTheDocument() + expect(screen.getByText('Place detail')).toBeInTheDocument() + expect(screen.queryByText('Adds costs')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-037: an unparseable capabilities blob degrades to the plain widget chip', async () => { + panelWith([plugin({ operatorEgress: false, permissions: 'not json', capabilities: JSON.stringify({ widget: {} }) })]) + render() + await screen.findByText('Gotify') + + expect(screen.getByText('Dashboard widget')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-038: dependency chips flag the addon and plugin the row is waiting on', async () => { + panelWith([plugin({ + operatorEgress: false, + dependencies: { requiredAddons: ['journey'], pluginDependencies: [{ id: 'trek-core', version: '^1.0.0' }] }, + dependencyIssues: { disabledAddons: ['journey'], missing: [{ id: 'trek-core', version: '^1.0.0' }], versionMismatch: [] }, + })]) + render() + + expect(await screen.findByText('Requires journey')).toBeInTheDocument() + expect(screen.getByText('Needs trek-core ^1.0.0')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-039: a plugin that names no TREK range says so', async () => { + panelWith([plugin({ operatorEgress: false, dependencyStatus: 'hostIncompatible' })]) + render() + + expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — Discover cards and the detail modal', () => { + function discoverWith(entry: Record, detail?: Record) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([registryEntry(entry)])), + http.get('*/api/admin/plugins/registry/trek-gotify', () => + detail ? HttpResponse.json(detail) : HttpResponse.json({ error: 'gone' }, { status: 404 })), + ) + } + + function manifestDetail(over: Record = {}, manifest: Record = {}) { + return { + id: 'trek-gotify', name: 'Gotify', author: 'Acme', description: 'Push', repo: 'acme/gotify', + type: 'integration', tags: [], size: 4096, publishedAt: null, latest: '2.0.0', + manifest: { permissions: [], egress: [], settings: [], license: 'MIT', icon: null, operatorEgress: false, ...manifest }, + ...over, + } + } + + it('FE-COMP-PLUGINS-PANEL-040: a card shows its compact download count and falls back on a broken shot', async () => { + discoverWith({ downloadCount: 1234, screenshotUrl: 'https://example.com/shot.png', reviewedAt: '2026-01-05T00:00:00Z' }) + const { container } = render() + await clickDiscover() + + expect(await screen.findByText('1.2k')).toBeInTheDocument() + const img = container.querySelector('img') as HTMLImageElement + expect(screen.getByText('Reviewed')).toBeInTheDocument() + expect(img).toBeInTheDocument() + fireEvent.error(img) + await waitFor(() => expect(container.querySelector('img')).not.toBeInTheDocument()) + }) + + it('FE-COMP-PLUGINS-PANEL-040a: a two-digit download count is printed verbatim', async () => { + discoverWith({ downloadCount: 42 }) + render() + await clickDiscover() + + expect(await screen.findByText('42')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-041: opening a card by keyboard shows the modal, and a failed lookup says so', async () => { + discoverWith({}) + render() + await clickDiscover() + + const card = (await screen.findByText('Gotify')).closest('[role="button"]') as HTMLElement + fireEvent.keyDown(card, { key: 'Enter' }) + + expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => { + discoverWith( + { downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' }, + manifestDetail({}, { + permissions: ['db:own'], + settings: [{ key: 'url', label: 'Server URL', scope: 'instance', required: true }], + }), + ) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText('Server URL')).toBeInTheDocument() + expect(screen.getByText('Instance-wide')).toBeInTheDocument() + expect(screen.getByText('Required')).toBeInTheDocument() + expect(screen.getByText('4 KB')).toBeInTheDocument() + expect(screen.getByText('TREK >=3.2.0')).toBeInTheDocument() + expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument() + expect(screen.getByRole('link', { name: /homepage/i })).toHaveAttribute('href', 'https://gotify.example') + // 999 500 rounds to a whole megabyte on the card, never "1000k" + expect(screen.getByText('1M')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-043: a manifest with no readable access says exactly that', async () => { + discoverWith({ minTrekVersion: '3.2.0' }, manifestDetail({ size: 0 })) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText('Needs no special access.')).toBeInTheDocument() + expect(screen.getByText('TREK 3.2.0+')).toBeInTheDocument() + expect(screen.queryByText('Connects to')).not.toBeInTheDocument() + expect(screen.queryByText('Setup')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-043b: a manifest that omits the lists still renders the modal', async () => { + const detail = manifestDetail({ size: 0 }) + delete (detail.manifest as Record).egress + delete (detail.manifest as Record).settings + delete (detail.manifest as Record).permissions + discoverWith({}, detail) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + expect(await screen.findByText('Needs no special access.')).toBeInTheDocument() + expect(screen.queryByText('Connects to')).not.toBeInTheDocument() + expect(screen.queryByText('Setup')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-044: an incompatible entry explains the blocked button in the modal body', async () => { + discoverWith({ compatible: false, latestCompatible: null, trek: '>=4.0.0', hostVersion: '3.3.0' }) + render() + await clickDiscover() + fireEvent.click(await screen.findByText('Gotify')) + + const explanations = await screen.findAllByText(/needs trek >=4\.0\.0 — this server runs 3\.3\.0/i) + expect(explanations.length).toBeGreaterThan(0) + }) + + it('FE-COMP-PLUGINS-PANEL-045: searching Discover with no hit says the registry has no match', async () => { + discoverWith({}) + render() + await clickDiscover() + await screen.findByText('Gotify') + + fireEvent.change(screen.getByPlaceholderText('Search plugins…'), { target: { value: 'nothing' } }) + + expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-046: installing from a card posts the offered version', async () => { + let body: unknown = null + discoverWith({ compatible: false, latestCompatible: '1.5.0', trek: '>=4.0.0', hostVersion: '3.3.0' }) + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json() + return HttpResponse.json({ ok: true }) + })) + withToast() + await clickDiscover() + + fireEvent.click(await screen.findByRole('button', { name: /^install 1\.5\.0$/i })) + + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })) + }) + + it('FE-COMP-PLUGINS-PANEL-047: an unreachable registry leaves Discover empty rather than spinning', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.error()), + ) + render() + await screen.findByText('No plugins installed yet.') + + await clickDiscover() + + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument() + }) +}) + +describe('AdminPluginsPanel — security footer', () => { + it('FE-COMP-PLUGINS-PANEL-048: the containment note expands into its sections', async () => { + panelWith([]) + render() + await screen.findByText('No plugins installed yet.') + + expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument() + fireEvent.click(screen.getByText('How plugins are contained — and the limits')) + + expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument() + expect(screen.getByText('What "Reviewed" means')).toBeInTheDocument() + expect(screen.getByText('What "Signed" means')).toBeInTheDocument() + }) +}) + +// FE-W5PLG-001 to FE-W5PLG-014 — toolbar filters and sorting, the error-log and +// allowed-hosts dialogs, and the drag-to-install overlay. +describe('AdminPluginsPanel toolbar and dialogs', () => { + const row = (over: Record = {}) => plugin({ + id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget', + version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false, + ...over, + }) + + function mockPanel(rows: Record[], registry: unknown = []) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)), + ) + } + + const three = () => [ + row(), + row({ id: 'b-integration', name: 'Beta Bridge', description: 'a bridge', type: 'integration', enabled: 0, status: 'inactive' }), + row({ id: 'c-page', name: 'Gamma Page', description: 'a page', type: 'page', enabled: 1, status: 'error', last_error: 'boom' }), + ] + + const pickFilter = async (user: ReturnType, filter: RegExp, option: RegExp) => { + await user.click(screen.getByTitle(filter)) + await user.click(screen.getByRole('button', { name: option })) + } + + it('FE-W5PLG-001: the search box narrows the installed list by name and description', async () => { + const user = userEvent.setup() + mockPanel(three()) + render() + await screen.findByText('Alpha Widget') + + await user.type(screen.getByPlaceholderText(/search/i), 'bridge') + + await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()) + expect(screen.getByText('Beta Bridge')).toBeInTheDocument() + }) + + it('FE-W5PLG-002: a search with no hits shows the no-match state', async () => { + const user = userEvent.setup() + mockPanel(three()) + render() + await screen.findByText('Alpha Widget') + + await user.type(screen.getByPlaceholderText(/search/i), 'zzzz') + + expect(await screen.findByText(/no.*match/i)).toBeInTheDocument() + }) + + it('FE-W5PLG-003: the type filter keeps only plugins of that type', async () => { + const user = userEvent.setup() + mockPanel(three()) + render() + await screen.findByText('Alpha Widget') + + await pickFilter(user, /^Type:/, /^Widget$/) + + await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) + expect(screen.getByText('Alpha Widget')).toBeInTheDocument() + }) + + it('FE-W5PLG-004: the status filter separates active, off and errored plugins', async () => { + const user = userEvent.setup() + mockPanel(three()) + render() + await screen.findByText('Alpha Widget') + + await pickFilter(user, /^Status:/, /^Active$/) + await waitFor(() => expect(screen.queryByText('Beta Bridge')).not.toBeInTheDocument()) + expect(screen.queryByText('Gamma Page')).not.toBeInTheDocument() + + await pickFilter(user, /^Status:/, /^Off$/) + await waitFor(() => expect(screen.getByText('Beta Bridge')).toBeInTheDocument()) + expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument() + + await pickFilter(user, /^Status:/, /^Error$/) + await waitFor(() => expect(screen.getByText('Gamma Page')).toBeInTheDocument()) + expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument() + }) + + it('FE-W5PLG-005: the update filter and the updates-first sort both use the registry version', async () => { + const user = userEvent.setup() + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) + render() + await screen.findByText('Alpha Widget') + await waitFor(() => expect(screen.getByText(/updates? available/i)).toBeInTheDocument()) + + await pickFilter(user, /^Sort:/, /^Updates first$/) + await waitFor(() => { + const names = screen.getAllByText(/Alpha Widget|Beta Bridge|Gamma Page/).map(e => e.textContent) + expect(names[0]).toBe('Gamma Page') + }) + + await pickFilter(user, /^Status:/, /^Update available$/) + await waitFor(() => expect(screen.queryByText('Alpha Widget')).not.toBeInTheDocument()) + expect(screen.getByText('Gamma Page')).toBeInTheDocument() + }) + + it('FE-W5PLG-006: switching tabs snaps a tab-only sort key back to name', async () => { + const user = userEvent.setup() + mockPanel(three(), [{ id: 'c-page', name: 'Gamma Page', latest: '9.9.9', latestCompatible: '9.9.9' }]) + render() + await screen.findByText('Alpha Widget') + + await pickFilter(user, /^Sort:/, /^Updates first$/) + expect(screen.getByTitle(/^Sort: Updates first$/)).toBeInTheDocument() + + await user.click(screen.getByRole('tab', { name: /Discover/ })) + await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument()) + }) + + it('FE-W5PLG-007: the registry sorts by downloads and by review date', async () => { + const user = userEvent.setup() + const reg = [ + { id: 'r-old', name: 'Old Tool', author: 'a', description: 'd', type: 'widget', downloadCount: 10, reviewedAt: '2024-01-01T00:00:00Z' }, + { id: 'r-new', name: 'New Tool', author: 'b', description: 'd', type: 'widget', downloadCount: 999, reviewedAt: '2026-01-01T00:00:00Z' }, + ] + mockPanel(three(), reg) + render() + await screen.findByText('Alpha Widget') + await user.click(screen.getByRole('tab', { name: /Discover/ })) + await screen.findByText('Old Tool') + + await pickFilter(user, /^Sort:/, /^Most downloads$/) + await waitFor(() => { + const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) + expect(names[0]).toBe('New Tool') + }) + + await pickFilter(user, /^Sort:/, /^Recently updated$/) + await waitFor(() => { + const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) + expect(names[0]).toBe('New Tool') + }) + + await pickFilter(user, /^Sort:/, /^Name$/) + await waitFor(() => { + const names = screen.getAllByText(/^(Old|New) Tool$/).map(e => e.textContent) + expect(names[0]).toBe('New Tool') + }) + }) + + it('FE-W5PLG-008: the registry search and type filter narrow the discover grid', async () => { + const user = userEvent.setup() + const reg = [ + { id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget' }, + { id: 'r-b', name: 'Poster', author: 'bob', description: 'posts things', type: 'page' }, + ] + mockPanel(three(), reg) + render() + await screen.findByText('Alpha Widget') + await user.click(screen.getByRole('tab', { name: /Discover/ })) + await screen.findByText('Mapper') + + await pickFilter(user, /^Type:/, /^Page$/) + await waitFor(() => expect(screen.queryByText('Mapper')).not.toBeInTheDocument()) + expect(screen.getByText('Poster')).toBeInTheDocument() + + await user.type(screen.getByPlaceholderText(/search/i), 'ann') + await waitFor(() => expect(screen.queryByText('Poster')).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-009: the error log lists both levels and closes again', async () => { + const user = userEvent.setup() + mockPanel([row()]) + server.use( + http.get('*/api/admin/plugins/a-widget/errors', () => + HttpResponse.json({ errors: [ + { level: 'error', ts: '2026-01-01 10:00', message: 'exploded' }, + { level: 'warn', ts: '2026-01-01 10:01', message: 'wobbled' }, + ] }), + ), + ) + render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget')) + await user.click(screen.getByRole('button', { name: /view error log/i })) + + expect(await screen.findByText('exploded')).toBeInTheDocument() + expect(screen.getByText('wobbled')).toBeInTheDocument() + expect(screen.getByText('error')).toBeInTheDocument() + expect(screen.getByText('warn')).toBeInTheDocument() + + const dialog = screen.getByText('exploded').closest('.fixed') as HTMLElement + await user.click(within(dialog).getAllByRole('button')[0]) + await waitFor(() => expect(screen.queryByText('exploded')).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-010: an empty error log says so and the backdrop closes it', async () => { + const user = userEvent.setup() + mockPanel([row()]) + server.use( + http.get('*/api/admin/plugins/a-widget/errors', () => HttpResponse.json({ errors: [] })), + ) + render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget')) + await user.click(screen.getByRole('button', { name: /view error log/i })) + + const empty = await screen.findByText(/no errors/i) + fireEvent.click(empty.closest('.fixed') as HTMLElement) + await waitFor(() => expect(screen.queryByText(/no errors/i)).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => { + const user = userEvent.setup() + mockPanel([row()]) + server.use( + http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })), + ) + render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget')) + await user.click(screen.getByRole('button', { name: /allowed hosts/i })) + + expect(await screen.findByText(/does not|not supported|no outbound/i)).toBeInTheDocument() + }) + + it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => { + const user = userEvent.setup() + mockPanel([row()]) + server.use( + http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ) + render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget')) + await user.click(screen.getByRole('button', { name: /allowed hosts/i })) + + expect(await screen.findByText(/a-widget —/)).toBeInTheDocument() + }) + + it('FE-W5PLG-013: dragging a file over the panel arms the drop overlay', async () => { + mockPanel([row()]) + const { container } = render() + await screen.findByText('Alpha Widget') + const panel = container.firstElementChild as HTMLElement + + fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } }) + expect(await screen.findByText(/drop.*to (upload|install)/i)).toBeInTheDocument() + + // nested enter/leave pairs must not disarm it early + fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'] } }) + fireEvent.dragLeave(panel) + expect(screen.getByText(/drop.*to (upload|install)/i)).toBeInTheDocument() + + fireEvent.dragLeave(panel) + await waitFor(() => expect(screen.queryByText(/drop.*to (upload|install)/i)).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-014: dropping a plugin archive uploads it', async () => { + let uploaded = false + mockPanel([row()]) + server.use( + http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'a-widget' }) }), + ) + const { container } = render( + <> + + + , + ) + await screen.findByText('Alpha Widget') + const panel = container.firstElementChild as HTMLElement + + const file = new File(['zip'], 'plugin.zip', { type: 'application/zip' }) + fireEvent.drop(panel, { dataTransfer: { files: [file] } }) + + await waitFor(() => expect(uploaded).toBe(true)) + }) + + it('FE-W5PLG-015: the click-away layer closes an open filter menu', async () => { + const user = userEvent.setup() + mockPanel([row()]) + const { container } = render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTitle(/^Type:/)) + expect(screen.getByRole('button', { name: /^Widget$/ })).toBeInTheDocument() + + const layer = container.querySelector('.fixed.inset-0.z-20') as HTMLElement + fireEvent.click(layer) + await waitFor(() => expect(screen.queryByRole('button', { name: /^Widget$/ })).not.toBeInTheDocument()) + }) +}) + +// FE-W5PLG-016 to FE-W5PLG-023 — registry cards, the detail modal and the +// dependency chips of an installed row. +describe('AdminPluginsPanel registry cards and dependency chips', () => { + const row = (over: Record = {}) => plugin({ + id: 'a-widget', name: 'Alpha Widget', description: 'a widget', type: 'widget', + version: '1.0.0', status: 'active', enabled: 1, operatorEgress: false, + permissions: JSON.stringify([]), + ...over, + }) + + const regItem = (over: Record = {}) => ({ + id: 'r-a', name: 'Mapper', author: 'ann', description: 'maps things', type: 'widget', + latest: '2.0.0', repo: 'ann/mapper', signed: true, ...over, + }) + + function mockPanel(rows: Record[], registry: unknown = []) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: rows })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)), + ) + } + + const openDiscover = async (user: ReturnType) => { + await user.click(screen.getByRole('tab', { name: /Discover/ })) + } + + it('FE-W5PLG-016: leaving Discover snaps the downloads sort back to name', async () => { + const user = userEvent.setup() + mockPanel([row()], [regItem({ downloadCount: 5 })]) + render() + await screen.findByText('Alpha Widget') + + await openDiscover(user) + await user.click(screen.getByTitle(/^Sort:/)) + await user.click(screen.getByRole('button', { name: /^Most downloads$/ })) + expect(screen.getByTitle(/^Sort: Most downloads$/)).toBeInTheDocument() + + await user.click(screen.getByRole('tab', { name: /Installed/ })) + await waitFor(() => expect(screen.getByTitle(/^Sort: Name$/)).toBeInTheDocument()) + }) + + it('FE-W5PLG-017: an outrun release offers the newest compatible version instead of a dead button', async () => { + const user = userEvent.setup() + mockPanel([row()], [ + regItem({ id: 'r-old', name: 'Older Fit', compatible: false, latestCompatible: '1.4.0', trek: '>=4', hostVersion: '3.5.0' }), + regItem({ id: 'r-dead', name: 'No Fit', compatible: false, latestCompatible: null, trek: null }), + ]) + render() + await screen.findByText('Alpha Widget') + await openDiscover(user) + + const offer = await screen.findByRole('button', { name: /^Install 1\.4\.0$/ }) + expect(offer).not.toBeDisabled() + expect(offer).toHaveAttribute('title', expect.stringContaining('3.5.0')) + + const dead = screen.getByRole('button', { name: /^Incompatible$/ }) + expect(dead).toBeDisabled() + expect(dead.getAttribute('title')).toBeTruthy() + }) + + it('FE-W5PLG-018: an already installed registry entry cannot be installed again', async () => { + const user = userEvent.setup() + mockPanel([row({ id: 'r-a', name: 'Mapper' })], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', downloadCount: 1234 })]) + render() + await screen.findByText('Mapper') + await openDiscover(user) + + expect(await screen.findByText('1.2k')).toBeInTheDocument() + const installBtn = screen.getAllByRole('button', { name: /^Installed$/ })[0] + expect(installBtn).toBeDisabled() + }) + + it('FE-W5PLG-019: a registry card opens its detail modal by click and by keyboard', async () => { + const user = userEvent.setup() + mockPanel([row()], [regItem({ reviewedAt: '2026-01-01T00:00:00Z', homepage: 'https://mapper.example' })]) + server.use( + http.get('*/api/admin/plugins/registry/r-a', () => + HttpResponse.json({ size: 4096, manifest: { icon: 'Map', permissions: ['db:read:trips'], egress: ['api.mapper.example'], operatorEgress: false, settings: [] } })), + ) + render() + await screen.findByText('Alpha Widget') + await openDiscover(user) + + const card = screen.getByText('Mapper').closest('[role="button"]') as HTMLElement + fireEvent.keyDown(card, { key: 'Enter' }) + expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('heading', { name: 'Mapper' }).closest('.fixed') as HTMLElement) + await waitFor(() => expect(screen.queryByRole('heading', { name: 'Mapper' })).not.toBeInTheDocument()) + + fireEvent.keyDown(card, { key: ' ' }) + expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument() + }) + + it('FE-W5PLG-020: a failing detail fetch still shows the modal shell', async () => { + const user = userEvent.setup() + mockPanel([row()], [regItem()]) + server.use( + http.get('*/api/admin/plugins/registry/r-a', () => HttpResponse.json({ error: 'gone' }, { status: 404 })), + ) + render() + await screen.findByText('Alpha Widget') + await openDiscover(user) + + await user.click(screen.getByText('Mapper')) + + expect(await screen.findByRole('heading', { name: 'Mapper' })).toBeInTheDocument() + expect(screen.getByText(/ann · v2\.0\.0/)).toBeInTheDocument() + }) + + it('FE-W5PLG-021: dependency chips flag a disabled addon and a missing plugin', async () => { + mockPanel([row({ + dependencies: { requiredAddons: ['budget', 'vacay'], pluginDependencies: [{ id: 'dep-a', version: '^1' }, { id: 'dep-b', version: '^2' }] }, + dependencyStatus: 'blocked', + dependencyIssues: { disabledAddons: ['vacay'], missing: [{ id: 'dep-a' }], versionMismatch: [{ id: 'dep-b' }] }, + })]) + render() + + await screen.findByText('Alpha Widget') + expect(screen.getByText(/budget/)).toBeInTheDocument() + expect(screen.getByText(/vacay/)).toBeInTheDocument() + expect(screen.getByText(/dep-a/)).toBeInTheDocument() + expect(screen.getByText(/dep-b/)).toBeInTheDocument() + }) + + it('FE-W5PLG-022: a plugin whose TREK range is unknown still gets an incompatibility chip', async () => { + mockPanel([row({ dependencyStatus: 'hostIncompatible', trekRange: null, hostVersion: null })]) + render() + + await screen.findByText('Alpha Widget') + expect(screen.getByText(/does not say which trek/i)).toBeInTheDocument() + }) + + it('FE-W5PLG-023: a registry-sourced row links to its repository and issue tracker', async () => { + const user = userEvent.setup() + mockPanel([row({ source_repo: 'ann/mapper', reviewed_at: '2026-01-01T00:00:00Z', version: null, description: null, status: 'weird' })]) + render() + await screen.findByText('Alpha Widget') + + await user.click(screen.getByTestId('plugin-row-menu-btn-a-widget')) + const repo = screen.getByRole('link', { name: /source|repository/i }) + expect(repo).toHaveAttribute('href', 'https://github.com/ann/mapper') + expect(screen.getByRole('link', { name: /issue/i })).toHaveAttribute('href', 'https://github.com/ann/mapper/issues') + + await user.click(repo) + await waitFor(() => expect(screen.queryByRole('link', { name: /issue/i })).not.toBeInTheDocument()) + }) +}) + +// FE-W5PLG-024 to FE-W5PLG-028 — the signature dialog's fingerprint rendering +// and the remaining dialog dismissals. +describe('AdminPluginsPanel signature fingerprints and dismissals', () => { + const blocked = (over: Record = {}) => plugin({ + source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa', + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' }, + ...over, + }) + + function mockPanel(row: Record, registry: unknown[] = []) { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [row] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)), + ) + } + + it('FE-W5PLG-024: a long new key is shown head…tail next to the pinned one', async () => { + const key = 'untrusted comment: minisign key\nRWQf6LRCGA9iA1234567890ABCDEFGH' + mockPanel(blocked(), [{ id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: key }]) + render() + await screen.findByText('Gotify') + + fireEvent.click(await screen.findByRole('button', { name: /review/i })) + + expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument() + expect(screen.getByText('RWQf6LRC…ABCDEFGH')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument() + }) + + it('FE-W5PLG-025: a short key is shown in full and a missing pinned key falls back to a dash', async () => { + mockPanel(blocked({ keyFingerprint: null }), [ + { id: 'trek-gotify', name: 'Gotify', author: 'acme', description: 'd', type: 'integration', latest: '2.0.0', authorPublicKey: 'SHORTKEY' }, + ]) + render() + await screen.findByText('Gotify') + + fireEvent.click(await screen.findByRole('button', { name: /review/i })) + + expect(await screen.findByText('SHORTKEY')).toBeInTheDocument() + expect(screen.getByText('—')).toBeInTheDocument() + }) + + it('FE-W5PLG-026: without a registry entry there is no key to offer and no override', async () => { + mockPanel(blocked()) + render() + + fireEvent.click(await screen.findByRole('button', { name: /review/i })) + + expect(await screen.findByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument() + expect(screen.getByText('—')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /trust the new key/i })).not.toBeInTheDocument() + }) + + it('FE-W5PLG-027: an incomplete signature gets its own explanation and closes on the backdrop', async () => { + mockPanel(blocked({ updateBlock: { code: 'SIGNATURE_INCOMPLETE', detail: 'only half signed', version: '2.0.0' } })) + render() + + fireEvent.click(await screen.findByRole('button', { name: /review/i })) + const detail = await screen.findByText('only half signed') + + fireEvent.click(detail.closest('.fixed') as HTMLElement) + await waitFor(() => expect(screen.queryByText('only half signed')).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-028: the allowed-hosts dialog closes from its header button and its backdrop', async () => { + const user = userEvent.setup() + mockPanel(plugin({ egressHostCount: 1 })) + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.com'] })), + ) + render() + + await user.click(await screen.findByRole('button', { name: /1 allowed host/i })) + const host = await screen.findByText('gotify.mydomain.com') + const dialog = host.closest('.fixed') as HTMLElement + + await user.click(within(dialog).getAllByRole('button')[0]) + await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument()) + + await user.click(screen.getByRole('button', { name: /1 allowed host/i })) + const reopened = (await screen.findByText('gotify.mydomain.com')).closest('.fixed') as HTMLElement + fireEvent.click(reopened) + await waitFor(() => expect(screen.queryByText('gotify.mydomain.com')).not.toBeInTheDocument()) + }) + + it('FE-W5PLG-029: a drop without a file is ignored', async () => { + let uploaded = false + mockPanel(plugin()) + server.use( + http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }) }), + ) + const { container } = render() + await screen.findByText('Gotify') + + fireEvent.drop(container.firstElementChild as HTMLElement, { dataTransfer: { files: [] } }) + + await waitFor(() => expect(uploaded).toBe(false)) + }) +}) + +// FE-W5PLG-030 — Update all drives the same single-slot busy flag as a per-row update, +// so the requests have to go out one after another. +describe('AdminPluginsPanel — update all', () => { + const updatableRow = (over: Record = {}) => plugin({ + id: 'a-widget', name: 'Alpha Widget', type: 'widget', version: '1.0.0', operatorEgress: false, ...over, + }) + + it('FE-W5PLG-030: Update all sends the second update only after the first came back', async () => { + const started: string[] = [] + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ + enabled: true, devLink: false, + plugins: [updatableRow(), updatableRow({ id: 'b-widget', name: 'Beta Widget' })], + })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([ + { id: 'a-widget', name: 'Alpha Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, + { id: 'b-widget', name: 'Beta Widget', author: 'acme', description: 'd', type: 'widget', latest: '2.0.0', latestCompatible: '2.0.0' }, + ])), + http.post('*/api/admin/plugins/:id/update', async ({ params }) => { + started.push(String(params.id)) + if (started.length === 1) await gate + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }) + }), + ) + render() + + fireEvent.click(await screen.findByRole('button', { name: /update all/i })) + + await waitFor(() => expect(started).toHaveLength(1)) + // Give a parallel dispatch every chance to show up before releasing the first. + await new Promise(resolve => setTimeout(resolve, 20)) + expect(started).toEqual(['a-widget']) + + release() + await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget'])) + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index d14b64654..9ba077f3f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react' +import { PLUGIN_PERMISSIONS } from '@trek/shared' import { createPortal } from 'react-dom' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, - Radio, Luggage, Globe, Image, CalendarDays, Bell, - Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, + Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' @@ -62,6 +63,8 @@ interface PluginRow { keyFingerprint?: string | null /** Why an update was refused, if one was. `version` is the version that was refused. */ updateBlock?: { code: string; detail: string | null; version: string | null } | null + /** A deliberate non-latest install paused updates: out of the banner/Update all until resumed. */ + updateHold?: boolean } interface RegistryItem { id: string @@ -92,19 +95,35 @@ interface RegistryItem { /** The full key — public, and carried in full because re-trust compares it exactly. */ authorPublicKey?: string | null } +/** One published version, with its SERVER-computed compat verdict (the client has no semver). */ +interface VersionInfo { + version: string + publishedAt: string | null + size: number | null + signed: boolean + /** The declared TREK requirement, or null when the entry carries no bounds at all. */ + trek: string | null + compatible: boolean +} interface RegistryDetail extends RegistryItem { size: number | null publishedAt: string | null + /** Every published version, newest first — the version picker's data. */ + versions?: VersionInfo[] manifest: { - permissions: string[] - egress: string[] + // Optional because a registry may omit an empty list — the detail view has to + // degrade rather than throw halfway through its render. + permissions?: string[] + egress?: string[] /** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */ operatorEgress?: boolean - settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> + settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }> license: string | null icon: string | null requiredAddons?: string[] pluginDependencies?: PluginDep[] + /** Display slice of the manifest's capabilities — drives the same chips as an installed row. */ + capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } } } | null } @@ -175,26 +194,14 @@ const HEALTH: Record = { } // Known permissions → human-readable i18n key; unknown ones render as raw code. -const PERM_KEYS = [ - 'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content', - 'db:read:collab', 'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections', - 'db:read:categories', 'db:read:tags', 'db:read:todos', 'weather:read', 'rates:read', 'db:write:costs', - 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:write:reservations', 'db:write:accommodations', 'db:write:daynotes', 'db:write:packing', - 'db:write:tags', 'db:write:todos', 'db:write:atlas', 'db:write:vacay', 'db:write:journal', 'db:write:collections', - 'db:write:files', 'db:write:collab', 'db:write:members', - 'db:create:trips', - 'db:meta', - 'notify:send', 'ai:invoke', 'oauth:client', - 'events:subscribe', 'jobs:run', - 'ws:broadcast:trip', 'ws:broadcast:user', - 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider', - 'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound', -] +// Generated from the host's protocol/envelope.ts by +// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. +const PERM_KEYS = PLUGIN_PERMISSIONS const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] function isNewer(a: string, b: string): boolean { - const nums = (v: string) => v.split('-')[0].split('.').map(n => parseInt(n, 10) || 0) + const nums = (v: string) => v.split('-')[0].split('.').map(n => Number.parseInt(n, 10) || 0) const pa = nums(a), pb = nums(b) for (let i = 0; i < 3; i++) { const x = pa[i] || 0, y = pb[i] || 0 @@ -239,6 +246,11 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') }) if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') }) if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') }) + if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') }) + if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') }) + if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') }) + if (perms.includes('hook:day-tint-provider')) out.push({ icon: Palette, label: t('admin.plugins.cap.dayTint') }) + if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') }) if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') }) if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') }) for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) { @@ -295,13 +307,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s return { blocked: true, label: t('admin.plugins.incompatible'), title } } -function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) { - if (compact) return - return ( - - {t('admin.plugins.reviewed')} - - ) +function ReviewedBadge({ t }: { t: T }) { + return } /** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */ @@ -399,6 +406,12 @@ export default function AdminPluginsPanel() { const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') const [confirmUninstall, setConfirmUninstall] = useState(null) + // The version picker for an INSTALLED plugin ("Change version…"); versions land async + // from the registry detail endpoint, which computes each version's compat verdict. + const [versionPicker, setVersionPicker] = useState<{ plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean } | null>(null) + // A picked version OLDER than the installed one — held here until the admin consents + // to the data risk (the plugin's data dir stays; older code may not understand it). + const [confirmDowngrade, setConfirmDowngrade] = useState<{ plugin: PluginRow; version: string } | null>(null) // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — // each must be shown, not silently overwritten by the last one. const [consentQueue, setConsentQueue] = useState>([]) @@ -427,10 +440,13 @@ export default function AdminPluginsPanel() { // Index the registry once per fetch: the version map the update badges read, plus the // whole entry (author key + signed flag) the trust badges and re-trust dialog need. + // The map holds latestCompatible — the newest version THIS TREK can install (computed + // server-side) — not the absolute latest, so the banner never counts an update the + // update endpoint would refuse. const indexRegistry = (items: RegistryItem[]) => { const vers: Record = {} const byId: Record = {} - items.forEach((i) => { byId[i.id] = i; if (i.latest) vers[i.id] = i.latest }) + items.forEach((i) => { byId[i.id] = i; if (i.latestCompatible) vers[i.id] = i.latestCompatible }) setLatest(vers) setRegById(byId) } @@ -558,7 +574,21 @@ export default function AdminPluginsPanel() { .catch(() => setErrorsFor({ id, rows: [] })) } - const updateAvailable = (p: PluginRow) => !!(p.version && latest[p.id] && isNewer(latest[p.id], p.version)) + // A held plugin (deliberate non-latest install) is deliberately NOT an update candidate: + // the admin just rolled it back, and the banner nagging them straight back would make + // the rollback fight the UI. The row carries its own "paused" marker + resume instead. + const updateAvailable = (p: PluginRow) => !!(p.version && !p.updateHold && latest[p.id] && isNewer(latest[p.id], p.version)) + const resumeUpdates = (p: PluginRow) => act(p.id, () => adminApi.pluginResumeUpdates(p.id), t('admin.plugins.updatesResumed')) + // A newer version exists but this TREK can't install it (and it isn't the one on offer): + // said passively on the row, so the admin learns a TREK upgrade unlocks it instead of + // wondering why no update shows. Null when the registry gives no range to point at. + const newerIncompatible = (p: PluginRow): { version: string; range: string } | null => { + const reg = regById[p.id] + if (!reg?.latest || !p.version || !isNewer(reg.latest, p.version)) return null + if (latest[p.id] === reg.latest) return null + const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null) + return range ? { version: reg.latest, range } : null + } const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed')) const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) // Dev-link: register a plugin from a local built directory (dev only). Reuses the @@ -646,9 +676,11 @@ export default function AdminPluginsPanel() { .finally(() => { setBusy(null); refresh() }) } - const runUpdate = (p: PluginRow) => { + // `version` pins the exact version to install (rollback / explicit switch); omitted, + // the server resolves the newest TREK-compatible version itself. + const runUpdate = (p: PluginRow, version?: string) => { setBusy(p.id); setMenu(null) - adminApi.pluginUpdate(p.id) + return adminApi.pluginUpdate(p.id, version) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) @@ -680,9 +712,36 @@ export default function AdminPluginsPanel() { .finally(() => { setRetrusting(false); refresh() }) } + // "Change version…" on an installed row: fetch the per-version list (with the server's + // compat verdicts) and open the picker. The fetch is keyed to the plugin so a stale + // response can't populate a picker that was reopened for another row. + const openVersionPicker = (p: PluginRow) => { + setMenu(null) + setVersionPicker({ plugin: p, versions: null, failed: false }) + adminApi.pluginDetail(p.id) + .then((d: RegistryDetail) => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, versions: d.versions ?? [] } : cur))) + .catch(() => setVersionPicker(cur => (cur && cur.plugin.id === p.id ? { ...cur, failed: true } : cur))) + } + + // Switching DOWN keeps the plugin's data dir, which the older code may not understand — + // that asks for explicit consent first. Any other switch is the ordinary update path. + const pickVersion = (version: string) => { + if (!versionPicker) return + const p = versionPicker.plugin + setVersionPicker(null) + if (p.version && isNewer(p.version, version)) setConfirmDowngrade({ plugin: p, version }) + else void runUpdate(p, version) + } + // eslint-disable-next-line react-hooks/exhaustive-deps const updatable = useMemo(() => plugins.filter(updateAvailable), [plugins, latest]) + // One after another: busy is a single slot, so parallel updates would leave the + // other rows clickable mid-install and refresh once per plugin. + const updateAll = async () => { + for (const p of updatable) await runUpdate(p) + } + // Installed list after search / type / status filters + sort. const shownInstalled = useMemo(() => { const term = q.trim().toLowerCase() @@ -740,7 +799,7 @@ export default function AdminPluginsPanel() {
)} {/* Click-away layer for any open dropdown (filters or a row's ⋯ menu). */} - {menu &&
setMenu(null)} />} + {menu &&
setMenu(null)} />} {/* Header */}
@@ -796,11 +855,11 @@ export default function AdminPluginsPanel() {
- - @@ -843,11 +902,11 @@ export default function AdminPluginsPanel() { valueLabel={sortLabel(sort, t)} onPick={v => setSort(v as SortKey)} /> - - @@ -872,7 +931,7 @@ export default function AdminPluginsPanel() {
{t('admin.plugins.updatesAvailable', { count: updatable.length })} - @@ -886,9 +945,12 @@ export default function AdminPluginsPanel() { ) : shownInstalled.map(p => ( toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} + onChangeVersion={() => openVersionPicker(p)} + onResume={() => void resumeUpdates(p)} onReviewBlock={() => setSignatureBlock({ subject: { id: p.id, name: p.name, keyFingerprint: p.keyFingerprint ?? null }, code: p.updateBlock!.code, detail: p.updateBlock!.detail, @@ -910,11 +972,11 @@ export default function AdminPluginsPanel() { {/* Error-log modal */} {errorsFor && ( -
setErrorsFor(null)}> -
e.stopPropagation()}> +
setErrorsFor(null)}> +
e.stopPropagation()}>
{errorsFor.id} — {t('admin.plugins.errorLog')} - +
{errorsFor.rows.length === 0 ?

{t('admin.plugins.noErrors')}

: @@ -932,11 +994,11 @@ export default function AdminPluginsPanel() { {/* Operator-supplied egress hosts */} {egressFor && ( -
setEgressFor(null)}> -
e.stopPropagation()}> +
setEgressFor(null)}> +
e.stopPropagation()}>
{egressFor.id} — {t('admin.plugins.allowedHosts')} - +
{!egressFor.supported ? ( @@ -950,7 +1012,7 @@ export default function AdminPluginsPanel() { {egressFor.hosts.map(h => (
{h} -

{t('admin.plugins.empty')}

-
) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked, onToggle, onUpdate, onRestart, onReviewBlock, onErrors, onEgress, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void - hasUpdate: boolean; latestVer?: string; blocked: boolean - onToggle: () => void; onUpdate: () => void; onRestart: () => void; onReviewBlock: () => void + hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean + onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void onErrors: () => void; onEgress: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) @@ -1144,7 +1220,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
{p.name} {p.version && v{p.version}} - {p.reviewed_at && } + {p.reviewed_at && } {p.source_repo === 'local:upload' && } {p.source_repo === 'local:link' && } {/* Registry plugins only — a sideloaded/dev-linked plugin already says something @@ -1159,12 +1235,32 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
{t('admin.plugins.updateBlocked', { reason: p.updateBlock.detail ?? p.updateBlock.code })} -
)} + {/* A newer version this TREK can't run — informational, never a button: the fix + is a TREK upgrade, so the row must not nag or offer a doomed install. */} + {newerIncompatible && ( +
+ + {t('admin.plugins.newerNeedsTrek', { version: newerIncompatible.version, range: newerIncompatible.range })} +
+ )} + {/* Held: the admin deliberately installed a non-latest version, so updates are + paused rather than nagged — resuming is one click, right where the pause shows. */} + {p.updateHold && ( +
+ + {t('admin.plugins.updatesHeld', { version: p.version ?? '' })} + +
+ )} {p.status === 'error' && p.last_error ? (
{p.last_error} @@ -1182,7 +1278,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked Actionable, and warning-toned until at least one host exists, because until then the plugin cannot reach anything and looks silently broken. */} {p.operatorEgress && ( - @@ -1222,7 +1318,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
- @@ -1234,6 +1330,10 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} + {isRegistrySourced(p.source_repo) && ( + } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> + )} {p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' && ( <> setMenu(null)} @@ -1276,7 +1376,7 @@ function anchorMenu(trigger: HTMLElement | null): CSSProperties { function MenuItem({ icon, label, onClick, danger }: { icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean }) { return ( - +
@@ -1420,11 +1523,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local

{item.name}

- {item.reviewedAt && } + {item.reviewedAt && }

{item.author}{item.latest ? ` · v${item.latest}` : ''}

- + ) + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ))} +
+
+ )}
@@ -1546,6 +1677,55 @@ function Meta({ k, v }: { k: string; v: string }) { return
{k}
{v}
} +/** + * "Change version…" for an INSTALLED plugin. Rows render the server's per-version compat + * verdict: the installed version is marked, a compatible one gets a Switch action (the + * caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is + * explained and never offered. + */ +function VersionPickerDialog({ plugin, versions, failed, busy, t, locale, onPick, onClose }: { + plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null + t: T; locale: string; onPick: (version: string) => void; onClose: () => void +}) { + return ( +
+
e.stopPropagation()}> +
+

{t('admin.plugins.versionPickerTitle', { name: plugin.name })}

+ +
+
+ {failed &&

{t('admin.plugins.detailError')}

} + {!failed && !versions &&

{t('common.loading')}

} + {versions?.length === 0 &&

{t('admin.plugins.noVersions')}

} + {versions?.map(v => { + const current = v.version === plugin.version + return ( +
+ v{v.version} + {v.publishedAt && {new Date(v.publishedAt).toLocaleDateString(locale)}} + {v.signed && } + + {current ? ( + {t('admin.plugins.installed')} + ) : v.compatible ? ( + + ) : ( + {t('admin.plugins.versionNeedsTrek', { range: v.trek ?? '' })} + )} +
+ ) + })} +
+
+
+ ) +} + /** * A signature check refused an install/update. * @@ -1580,8 +1760,8 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { : 'admin.plugins.sig.invalidBody' return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1604,12 +1784,12 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { )}
- {offerRetrust && ( - @@ -1644,8 +1824,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { t: T; onApprove: () => void; onLater: () => void }) { return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1683,8 +1863,8 @@ function UpdateConsentDialog({ data, unsigned, t, onApprove, onLater }: { )}
- - + +
@@ -1704,8 +1884,8 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ...data.versionMismatch.map(d => ({ id: d.id, constraint: d.wanted, installed: d.installed })), ] return ( -
-
e.stopPropagation()}> +
+
e.stopPropagation()}>
@@ -1724,7 +1904,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl : t('admin.plugins.dep.requires', { version: r.constraint })}
- @@ -1735,7 +1915,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl )}
- +
@@ -1761,7 +1941,7 @@ function SecurityInfo({ t }: { t: T }) {

{t('admin.plugins.reviewedMeaning')}

-
- -
@@ -274,14 +274,14 @@ export default function BackupPanel() {
- - - -
-
- - diff --git a/client/src/components/Admin/DefaultUserSettingsTab.test.tsx b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx new file mode 100644 index 000000000..2ed73a677 --- /dev/null +++ b/client/src/components/Admin/DefaultUserSettingsTab.test.tsx @@ -0,0 +1,455 @@ +// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027 +import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../tests/helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import { buildAdmin } from '../../../tests/helpers/factories'; +import { useAuthStore } from '../../store/authStore'; +import { ToastContainer } from '../shared/Toast'; +import DefaultUserSettingsTab from './DefaultUserSettingsTab'; + +// The tile preview would pull Leaflet into jsdom; the panel only needs it to render. +vi.mock('../Map/MapView', () => ({ + MapView: ({ tileUrl }: { tileUrl?: string }) =>
, +})); + +const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard'; +const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11'; +const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1'; +const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty'; +const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + +/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */ +function stubDefaults(initial: Record = {}) { + const state: Record = { ...initial }; + const puts: Record[] = []; + server.use( + http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)), + http.put('/api/admin/default-user-settings', async ({ request }) => { + const body = await request.json() as Record; + puts.push(body); + for (const [key, value] of Object.entries(body)) { + if (value === null) delete state[key]; + else state[key] = value; + } + return HttpResponse.json({ ...state }); + }), + ); + return { puts, state }; +} + +function withToast() { + return render(<>); +} + +/** The selected option button is the one drawn with the strong border token. */ +function isActive(button: HTMLElement): boolean { + return (button.style.border || '').includes('var(--text-primary)'); +} + +/** + * The reset link sits inside the field's own
- @@ -170,8 +171,8 @@ export default function PackingTemplateManager() { setCreateName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }} placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} /> - - + +
)} @@ -186,7 +187,7 @@ export default function PackingTemplateManager() {
{/* Template row */}
- @@ -196,14 +197,14 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }} className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" /> ) : ( - toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name} + )} {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')} - -
@@ -227,11 +228,11 @@ export default function PackingTemplateManager() { {cat.name} )} {catItems.length} - - -
@@ -245,15 +246,15 @@ export default function PackingTemplateManager() { setEditItemName(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }} className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> - - + + ) : ( <> {item.name} - - )} @@ -267,9 +268,9 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }} placeholder={t('admin.packingTemplates.itemName')} className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" /> - -
)} @@ -286,11 +287,11 @@ export default function PackingTemplateManager() { onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }} placeholder={t('admin.packingTemplates.categoryName')} className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" /> - - + +
) : ( - diff --git a/client/src/components/Admin/PermissionsPanel.test.tsx b/client/src/components/Admin/PermissionsPanel.test.tsx index fb7323ecf..d53ab7464 100644 --- a/client/src/components/Admin/PermissionsPanel.test.tsx +++ b/client/src/components/Admin/PermissionsPanel.test.tsx @@ -269,6 +269,6 @@ describe('PermissionsPanel', () => { ), ); renderPanel(); - await screen.findByText('Error'); + expect(await screen.findByText('Error')).toBeInTheDocument(); }); }); diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx index acab4f0c6..6b2b149b7 100644 --- a/client/src/components/Admin/PermissionsPanel.tsx +++ b/client/src/components/Admin/PermissionsPanel.tsx @@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {

{t('perm.subtitle')}

- - +

+
+ {rows.map((row) => { + const resultKey = row.mirrorName ?? row.name + const result = testResultFor(resultKey) + // row.mirrorName only exists when foldBackends adopted a draft mirror + // for this row, so the draft lookup below cannot miss. + const testCandidate = row.mirrorName + ? draft.backends.find((b) => b.name === row.mirrorName)! + : row.backend + const rowUsage = usageSums?.[row.name] + const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined + return ( +
+
+ {row.name} + + {t(`storage.type.${row.type}`)} + + + {t(`storage.source.${row.source}`)} + + + + {row.source !== 'env' && ( + + )} + {row.source === 'settings' && ( + + )} +
+

+ {row.categories.length > 0 + ? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) }) + : t('storage.backends.unused')} +

+ {rowUsage && ( +

+ {t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })} + {row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0 + ? ` (${t('storage.usage.legacyNote')})` + : ''} +

+ )} + {row.mirrorTargets.length > 0 && ( +

+ {t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })} +

+ )} + {row.replicaOf.length > 0 && ( +

+ {t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })} +

+ )} + {row.source === 'env' &&

{t('storage.backends.envReadOnly')}

} + {result === 'running' ? ( +

{t('storage.test.running')}

+ ) : ( + + )} + {row.mirrorTargets.length > 0 && ( +
+ {backfill?.status === 'running' ? ( + <> +

+ {t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })} +

+

+ {t('storage.sync.counts', { + copied: String(backfill.copied), + skipped: String(backfill.skipped), + failed: String(backfill.failed), + })} +

+ + + ) : ( + <> + {/* Terminal statuses render ALONGSIDE the button (not instead-of) — + a completed/cancelled/errored backfill must stay re-runnable + without a page reload. */} + {backfill?.status === 'done' && ( +

+ {t('storage.sync.done', { + copied: String(backfill.copied), + deleted: String(backfill.deleted), + failed: String(backfill.failed), + })} +

+ )} + {backfill?.status === 'cancelled' && ( +

{t('storage.sync.cancelled')}

+ )} + {backfill?.status === 'error' && ( +

+ {t('storage.sync.error', { error: backfill.error ?? '' })} +

+ )} + {syncPrompt !== row.name && ( + + )} + + )} +
+ )} + {syncPrompt === row.name && ( +
+

{t('storage.sync.prompt')}

+
+ + +
+
+ )} +
+ ) + })} + + {degenerate.map(({ backend, reason }) => { + const result = testResultFor(backend.name) + const primary = backend.type === 'mirror' ? backend.options.primary : '' + return ( +
+
+ {backend.name} + + {t('storage.type.mirror')} + + + + +
+

{t(`storage.mirror.degenerate.${reason}`, { primary })}

+ {result === 'running' ? ( +

{t('storage.test.running')}

+ ) : ( + + )} +
+ ) + })} +
+ + {state.migrations.length > 0 && ( +
+ {state.migrations.map((m) => ( +
+

{t(`storage.category.${m.category}`)}

+ {m.status === 'running' ? ( + <> +

+ {t('storage.migrate.running', { + category: t(`storage.category.${m.category}`), + done: String(m.done), + total: String(m.total), + })} +

+ + + ) : m.status === 'done' ? ( + <> +

+ {t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })} +

+ {m.failed > 0 && ( +

+ {t('storage.migrate.doneFailures', { failed: String(m.failed) })} +

+ )} + {m.reclaimable && ( +

+ {t('storage.migrate.reclaimable', { + objects: String(m.reclaimable.objects), + size: formatBytes(m.reclaimable.bytes), + from: m.from, + })} +

+ )} + + ) : m.status === 'failed' ? ( +

+ {t('storage.migrate.failed', { error: m.error ?? '' })} +

+ ) : ( +

{t('storage.migrate.cancelled')}

+ )} +
+ ))} +
+ )} + + {editing ? ( + setEditing(null)} + /> + ) : ( + + )} + + +
+
+ {STORAGE_CATEGORIES.map((category) => { + // The admin state's category record is exhaustive by schema contract. + const stateEntry = state.categories[category]! + const selectedPrimary = primaryNameOf(state, draft, effective[category]) + const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend) + const viaMirror = effective[category] !== selectedPrimary + return ( +
+ +

{t(`storage.categoryDesc.${category}`)}

+ {state.usage?.categories[category] && ( +

+ {t('storage.usage.line', { + objects: String(state.usage.categories[category]!.objects), + size: formatBytes(state.usage.categories[category]!.bytes), + })} +

+ )} + setCategory(category, String(value))} + options={rows.map((row) => ({ + value: row.name, + label: + stateEntry.source === 'default' && row.name === stateEntry.backend + ? `${row.name} (${t('storage.categories.default')})` + : row.name, + }))} + size="sm" + style={{ maxWidth: 320 }} + /> + {changed && ( +

+ {t('storage.categories.reassignWarning')} +

+ )} + {viaMirror && CACHE_CATEGORIES.includes(category) && ( +

+ {t('storage.mirror.cacheWarning')} +

+ )} +
+ ) + })} +
+
+ +
+ + {admin.dirty && {t('storage.unsaved')}} +
+ {migrationQueue.length > 0 && ( +

+ {t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })} +

+ )} + {migratePromptOpen && ( +
+

{t('storage.migrate.promptTitle')}

+ {migrationCandidates.map((c) => ( +

+ {c.objects === null + ? t('storage.migrate.promptLineUnknown', { + category: t(`storage.category.${c.category}`), + from: c.from, + to: c.to, + }) + : t('storage.migrate.promptLine', { + category: t(`storage.category.${c.category}`), + objects: String(c.objects), + size: formatBytes(c.bytes ?? 0), + from: c.from, + to: c.to, + })} +

+ ))} +
+ + + +
+
+ )} + {admin.saveError && ( +
+

{admin.saveError}

+ {/* A 409 leaves the draft pinned to a version the server has moved + past; "save again" can never clear it, so the banner offers the + one action that can — see useStorageAdmin.discardDraft. */} + {admin.saveConflict && ( + + )} +
+ )} + + setConfirmRemove(null)} + onConfirm={() => { + if (confirmRemove) { + admin.setDraft( + confirmRemove.degenerate + ? removeBackend(draft, confirmRemove.name) + : removeBackendAndMirrors(state, draft, confirmRemove.name), + ) + } + setConfirmRemove(null) + }} + title={t('storage.remove.title')} + message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''} + confirmLabel={t('storage.remove.title')} + /> +
+ ) +} diff --git a/client/src/components/Admin/storage/BackendForm.test.tsx b/client/src/components/Admin/storage/BackendForm.test.tsx new file mode 100644 index 000000000..2bb69afee --- /dev/null +++ b/client/src/components/Admin/storage/BackendForm.test.tsx @@ -0,0 +1,155 @@ +import { describe, expect, it, vi } from 'vitest'; +import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared'; +import { fireEvent, render, screen } from '../../../../tests/helpers/render'; +import BackendForm from './BackendForm'; + +const NAMES = ['uploads-local', 'backups-local', 'off-box']; + +type FormProps = Parameters[0]; + +function renderForm(overrides: Partial = {}) { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + ); + return { onCommit, onCancel }; +} + +const S3_INITIAL: StorageBackend = { + name: 'off-box', + type: 's3', + options: { + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', + secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000, + }, +}; + +describe('BackendForm', () => { + it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => { + renderForm(); + expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument(); + expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => { + renderForm({ initial: S3_INITIAL }); + expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text'); + expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password'); + expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1'); + expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number'); + expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1'); + expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000'); + }); + + it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => { + const { onCommit } = renderForm({ initial: S3_INITIAL }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'off-box', + type: 's3', + options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }), + }), + ); + }); + + it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => { + renderForm({ initial: S3_INITIAL }); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } }); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => { + const { onCommit } = renderForm(); + const apply = screen.getByRole('button', { name: 'Apply' }); + expect(apply).toBeDisabled(); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } }); + expect(apply).toBeDisabled(); // root still empty + fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } }); + expect(apply).toBeEnabled(); + fireEvent.click(apply); + expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }); + }); + + it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => { + renderForm(); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } }); + fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } }); + expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + }); + + it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => { + const { onCommit } = renderForm(); + // Switch to s3 via the type select (CustomSelect renders options into a portal). + fireEvent.click(screen.getByText('Local')); + fireEvent.click(screen.getByText('S3')); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } }); + fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } }); + fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } }); + fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } }); + fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } }); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + const committed = onCommit.mock.calls[0]![0] as StorageBackend; + expect(committed.options).toEqual({ + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk', + }); + expect(committed.options).not.toHaveProperty('region'); + }); + + it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => { + renderForm({ initial: S3_INITIAL }); + expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled(); + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } }); + expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + // Keeping the original name stays allowed. + fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } }); + expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled(); + }); + + it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => { + renderForm(); + fireEvent.click(screen.getByText('Local')); // open the type select + expect(screen.queryByText('Mirror')).not.toBeInTheDocument(); + expect(screen.getByText('S3')).toBeInTheDocument(); + }); + + it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => { + const { onCommit } = renderForm({ + initial: S3_INITIAL, + mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] }, + }); + expect(screen.getByText('Mirror targets')).toBeInTheDocument(); + const boxes = screen.getAllByRole('checkbox'); + expect(boxes).toHaveLength(2); // off-box (self) excluded + expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked(); + expect(screen.getByRole('note').textContent).toContain('slows every upload'); + fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' })); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onCommit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'off-box', type: 's3' }), + ['backups-local', 'uploads-local'], + ); + }); + + it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => { + const { onCommit } = renderForm({ + initial: S3_INITIAL, + mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' })); + expect(screen.queryByRole('note')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Apply' })); + expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []); + }); +}); diff --git a/client/src/components/Admin/storage/BackendForm.tsx b/client/src/components/Admin/storage/BackendForm.tsx new file mode 100644 index 000000000..1731a1623 --- /dev/null +++ b/client/src/components/Admin/storage/BackendForm.tsx @@ -0,0 +1,264 @@ +import React, { useState } from 'react' +import { + STORAGE_BACKEND_TYPES, + STORAGE_BACKEND_TYPE_IDS, + type StorageBackend, + type StorageBackendFieldDef, + type StorageBackendTypeId, +} from '@trek/shared' +import { useTranslation } from '../../../i18n' +import CustomSelect from '../../shared/CustomSelect' + +type FieldValues = Record + +function valuesOf(backend: StorageBackend | null): FieldValues { + if (!backend) return {} + const values: FieldValues = {} + for (const [key, value] of Object.entries(backend.options)) { + values[key] = Array.isArray(value) ? value : String(value) + } + return values +} + +export interface BackendFormMirrorProps { + /** Selectable replica targets (the form excludes its own name at render). */ + candidates: string[] + initialTargets: string[] +} + +interface BackendFormProps { + /** null = new backend */ + initial: StorageBackend | null + /** Every defined backend name — mirror-target options and the duplicate pre-check. */ + backendNames: string[] + /** + * Present on non-mirror backends: renders the Mirror-targets composer + * (replicas-on-primary — panel-supplied chrome, never a registry field). + * When present, onCommit's second argument is always an array. + */ + mirror?: BackendFormMirrorProps + onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void + onCancel: () => void +} + +const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary' +const INPUT_CLASS = + 'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content' + +/** + * Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw + * `mirror` type is hidden from the type select — mirrors are composed via the + * Mirror-targets prop block and synthesized by the panels (the ref-kind + * renderers below stay for future registry types). + */ +export default function BackendForm({ + initial, + backendNames, + mirror, + onCommit, + onCancel, +}: BackendFormProps): React.ReactElement { + const { t } = useTranslation() + const [type, setType] = useState(initial?.type ?? 'local') + const [name, setName] = useState(initial?.name ?? '') + const [values, setValues] = useState(() => valuesOf(initial)) + const [targets, setTargets] = useState(mirror?.initialTargets ?? []) + + const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[] + const refOptions = backendNames.filter((candidate) => candidate !== name.trim()) + const setValue = (key: string, value: string | string[]) => + setValues((prev) => ({ ...prev, [key]: value })) + + const filled = (field: StorageBackendFieldDef): boolean => { + const value = values[field.key] + if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0 + return typeof value === 'string' && value.trim() !== '' + } + const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim()) + const canApply = + name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f)) + + const apply = () => { + const options: Record = {} + for (const field of fields) { + const value = values[field.key] + if (field.kind === 'backend-ref-list') { + options[field.key] = Array.isArray(value) ? value : [] + continue + } + const text = typeof value === 'string' ? value : '' + if (text === '' && !field.required) continue // omitted → the shared schema default applies + options[field.key] = field.kind === 'number' ? Number(text) : text + } + // The options were built from the same field defs the schema is generated + // from — this is the same sanctioned cast storageModel.asWireBackend makes. + const payload = { name: name.trim(), type, options } as StorageBackend + // Arity matters: the landed tests pin single-argument calls when no mirror + // block is supplied (toHaveBeenCalledWith treats a trailing undefined as a + // mismatch), so the second argument exists only when the composer does. + if (mirror) onCommit(payload, targets) + else onCommit(payload) + } + + const renderField = (field: StorageBackendFieldDef): React.ReactElement => { + const value = values[field.key] + if (field.kind === 'backend-ref') { + return ( +
+ {t(field.labelKey)} + setValue(field.key, String(next))} + options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))} + placeholder={t(field.labelKey)} + size="sm" + /> +
+ ) + } + if (field.kind === 'backend-ref-list') { + const selected = Array.isArray(value) ? value : [] + return ( +
+ {t(field.labelKey)} +
+ {refOptions.map((candidate) => ( + + ))} +
+
+ ) + } + const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text' + return ( + + ) + } + + return ( +
+

+ {initial ? t('storage.form.editTitle') : t('storage.form.addTitle')} +

+ + + {duplicate && ( +

+ {t('storage.form.duplicateName', { name: name.trim() })} +

+ )} + +
+ {t('storage.form.type')} + { + setType(next as StorageBackendTypeId) + setValues({}) // a different type has different fields + }} + options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({ + value: id, + label: t(`storage.type.${id}`), + }))} + size="sm" + disabled={initial !== null} + /> +
+ + {fields.map(renderField)} + + {mirror && ( +
+ {t('storage.mirror.targets')} +

{t('storage.mirror.targetsHelp')}

+
+ {mirror.candidates + .filter((candidate) => candidate !== name.trim()) + .map((candidate) => ( + + ))} +
+ {targets.length > 0 && ( +

+ {t('storage.mirror.latencyNote')} +

+ )} +
+ )} + +
+ + +
+
+ ) +} diff --git a/client/src/components/Admin/storage/storageModel.test.ts b/client/src/components/Admin/storage/storageModel.test.ts new file mode 100644 index 000000000..1d400ee21 --- /dev/null +++ b/client/src/components/Admin/storage/storageModel.test.ts @@ -0,0 +1,407 @@ +import { describe, expect, it } from 'vitest'; +import { + MASKED_SETTING_VALUE, + STORAGE_CATEGORIES, + type StorageAdminState, + type StorageBackend, + type StorageCategory, + type StorageConfig, +} from '@trek/shared'; +import { + categoriesPointingAt, + computeMigrationCandidates, + foldBackends, + mirrorProbeTargets, + mirrorsReferencing, + primaryNameOf, + removeBackend, + removeBackendAndMirrors, + renameBackendRefs, + replicaCandidates, + replicaOfPrimaries, + setMirrorTargets, + settingsDocumentOf, + stripCategories, + upsertBackend, + usageByBackend, +} from './storageModel'; + +const STATE: StorageAdminState = { + backends: [ + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] }, + { name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] }, + { + name: 'off-box', + type: 's3', + source: 'settings', + options: { + endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', + secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000, + }, + categories: ['covers'], + }, + ], + categories: { + files: { backend: 'uploads-local', source: 'default' }, + journey: { backend: 'uploads-local', source: 'default' }, + covers: { backend: 'off-box', source: 'settings' }, + avatars: { backend: 'uploads-local', source: 'default' }, + places: { backend: 'place-photos-local', source: 'default' }, + 'photos-google': { backend: 'place-photos-local', source: 'default' }, + 'photos-trek': { backend: 'uploads-local', source: 'default' }, + backups: { backend: 'backups-local', source: 'default' }, + }, + health: { replicaFailures: [] }, + seedFilePresent: false, + usage: null, + backfills: [], + migrations: [], + version: 5, + configError: null, +}; + +describe('settingsDocumentOf', () => { + it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => { + const doc = settingsDocumentOf(STATE); + expect(doc.backends.map((b) => b.name)).toEqual(['off-box']); + expect(doc.categories).toEqual({ covers: 'off-box' }); + }); + + it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => { + expect(settingsDocumentOf(STATE).version).toBe(5); + expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12); + }); +}); + +describe('remove pre-check helpers', () => { + const draft: StorageConfig = { + backends: [ + { name: 'nas', type: 'local', options: { root: '/mnt/nas' } }, + { name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } }, + ], + categories: { covers: 'nas', backups: 'mir' }, + }; + + it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => { + expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']); + expect(categoriesPointingAt(draft, 'unused')).toEqual([]); + }); + + it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => { + expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']); + expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']); + expect(mirrorsReferencing(draft, 'unused')).toEqual([]); + }); +}); + + +describe('draft edits', () => { + const draft: StorageConfig = { + backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }], + categories: {}, + }; + + it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => { + const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }); + expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]); + const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } }); + expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']); + expect(draft.backends).toHaveLength(1); // untouched + }); + + it('FE-ADMIN-STORM-006: removeBackend filters by name', () => { + expect(removeBackend(draft, 'nas').backends).toEqual([]); + expect(removeBackend(draft, 'ghost').backends).toHaveLength(1); + }); +}); + +// Hoisted to module scope (not just the mirror-fold describe below) so the +// usageByBackend suite can reuse the same mirrored fixture the brief's test +// snippet assumes is in scope. +const MIRRORED_STATE: StorageAdminState = { + ...STATE, + backends: [ + ...STATE.backends, + { + name: 'mirror', type: 'mirror', source: 'settings', + options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'], + }, + { name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] }, + ], + categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } }, +}; +const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE); + +describe('mirror fold/synthesize (replicas-on-primary)', () => { + it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => { + const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft()); + expect(degenerate).toEqual([]); + expect(rows.map((r) => r.name)).not.toContain('mirror'); + const primary = rows.find((r) => r.name === 'backups-local')!; + expect(primary.mirrorTargets).toEqual(['off-box']); + expect(primary.mirrorName).toBe('mirror'); + expect(primary.categories).toContain('backups'); // via the mirror + const replica = rows.find((r) => r.name === 'off-box')!; + expect(replica.replicaOf).toEqual(['backups-local']); + expect(replica.categories).toContain('covers'); // its own direct assignment survives + }); + + it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => { + const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local + const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']); + const mirror = next.backends.find((b) => b.type === 'mirror')!; + expect(mirror.name).toBe('backups-local-mirror'); + expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] }); + expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten + expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched + }); + + it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => { + const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']); + const mirrors = next.backends.filter((b) => b.type === 'mirror'); + expect(mirrors).toHaveLength(1); + expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed + expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] }); + }); + + it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => { + // Build the synthesized state locally: default backups routed via a fresh mirror… + const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']); + // …then dissolve it: the backups entry must DROP (state says its default is backups-local). + const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []); + expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false); + expect(dissolved.categories).not.toHaveProperty('backups'); + // Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping. + const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []); + expect(repointed.categories.backups).toBe('backups-local'); + }); + + it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => { + const draft: StorageConfig = { + backends: [ + { name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend, + { name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } }, + { name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } }, + { name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } }, + { name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } }, + ], + categories: {}, + }; + const { rows, degenerate } = foldBackends(STATE, draft); + expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins + expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([ + ['m2', 'duplicate-mirror'], + ['m-env', 'env-primary'], + ['m-ghost', 'missing-primary'], + ]); + }); + + it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => { + const draft: StorageConfig = { + backends: [ + { name: 'nas', type: 'local', options: { root: '/mnt/nas' } }, + { name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } }, + ], + categories: { covers: 'nas' }, + }; + const renamed = renameBackendRefs(draft, 'nas', 'nas2'); + const mirror = renamed.backends.find((b) => b.type === 'mirror')!; + expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] }); + expect(renamed.categories.covers).toBe('nas2'); + expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job + }); + + it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => { + const draft = mirroredDraft(); + expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']); + const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local'); + expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false); + expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false); + }); + + it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => { + // Single-replica mirror: stripping off-box empties it → dissolve + re-point + // (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops). + const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box'); + expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false); + expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false); + expect(dissolved.categories.backups).toBe('backups-local'); + // Multi-replica mirror: stripping one target keeps the mirror alive with the rest. + const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']); + const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box'); + const mirror = survived.backends.find((b) => b.type === 'mirror')!; + expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] }); + expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror + }); + + it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => { + expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local'); + expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback + expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local'); + }); +}); + +describe('replicaCandidates', () => { + it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => { + const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft()); + // uploads-local/place-photos-local serve categories directly; + // backups-local serves backups through the mirror it is the primary of; + // off-box carries a direct `covers` assignment. Nothing is offerable. + expect(replicaCandidates(rows, 'backups-local')).toEqual([]); + }); + + it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => { + const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft()); + expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']); + }); + + it('FE-ADMIN-STORM-022: an unassigned row is offered', () => { + const state: StorageAdminState = { + ...MIRRORED_STATE, + backends: [ + ...MIRRORED_STATE.backends, + { name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] }, + ], + }; + const { rows } = foldBackends(state, settingsDocumentOf(state)); + expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']); + }); +}); + +describe('usageByBackend', () => { + it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => { + const usage = { + computedAt: 1, + categories: Object.fromEntries( + STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]), + ) as Record, + legacyPhotos: { objects: 5, bytes: 50 }, + }; + const state = { ...MIRRORED_STATE, usage }; + const sums = usageByBackend(state, mirroredDraft())!; + // backups routes via the mirror → attributed to backups-local (the primary row). + expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 }); + // uploads-local serves 4 direct categories (files, journey, avatars, photos-trek — + // this fixture's `places` and `photos-google` default to the env-sourced + // place-photos-local backend, not uploads-local) + legacy photos. + expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 }); + // places + photos-google both default to place-photos-local. + expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 }); + // covers routes to off-box directly. + expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 }); + }); + + it('FE-ADMIN-STORM-017: null usage yields null', () => { + expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull(); + }); +}); + +describe('computeMigrationCandidates', () => { + it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => { + const state: StorageAdminState = { + ...STATE, + usage: { + computedAt: 1, + categories: { + files: { objects: 3, bytes: 300 }, + journey: { objects: 0, bytes: 0 }, + } as Record, + legacyPhotos: { objects: 0, bytes: 0 }, + }, + }; + const base = settingsDocumentOf(state); + const draft: StorageConfig = { + ...base, + categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' }, + }; + const candidates = computeMigrationCandidates(draft, state); + expect(candidates).toEqual([ + { category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 }, + { category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null }, + ]); + }); + + it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => { + const state: StorageAdminState = { + ...STATE, + backends: [ + ...STATE.backends, + { name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] }, + { + name: 'off-box-mirror', + type: 'mirror', + source: 'settings', + options: { primary: 'off-box', replicas: ['nas'] }, + categories: [], + }, + ], + usage: { + computedAt: 1, + categories: { files: { objects: 3, bytes: 300 } } as Record, + legacyPhotos: { objects: 0, bytes: 0 }, + }, + }; + const base = settingsDocumentOf(state); + // Reassign 'files' to the mirrored primary's WIRE name — the same value + // setCategory() writes into the draft when the operator picks a mirrored + // primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name). + const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } }; + const candidates = computeMigrationCandidates(draft, state); + expect(candidates).toEqual([ + { category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 }, + ]); + }); + + it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => { + const base = settingsDocumentOf(STATE); + const draft: StorageConfig = { + ...base, + categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' }, + }; + + const strippedFiles = stripCategories(draft, STATE, ['files']); + expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override" + expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list + + const strippedCovers = stripCategories(draft, STATE, ['covers']); + expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly + expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list + }); +}); + +describe('mirrorProbeTargets', () => { + it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => { + const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } }; + expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]); + }); + + it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => { + const mirror: StorageBackend = { + name: 'mirror', type: 'mirror', + options: { primary: 'uploads-local', replicas: ['off-box'] }, + }; + // The draft carries an EDITED off-box (unsaved endpoint change) but never + // touches uploads-local (a built-in, never draft-owned). + const editedOffBox = { + name: 'off-box', type: 's3', + options: { ...(STATE.backends[2]!.options as Record), endpoint: 'http://edited:9000' }, + } as StorageBackend; + const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} }; + + const targets = mirrorProbeTargets(draft, STATE, mirror); + expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']); + // Primary: no draft entry — resolved from state (the live built-in). + expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } }); + // Replica: draft override wins — the unsaved edit, not the saved options. + expect((targets[1]!.options as Record).endpoint).toBe('http://edited:9000'); + }); + + it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => { + const mirror: StorageBackend = { + name: 'mirror', type: 'mirror', + options: { primary: 'uploads-local', replicas: ['ghost'] }, + }; + const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror); + expect(targets.map((t) => t.name)).toEqual(['uploads-local']); + }); +}); diff --git a/client/src/components/Admin/storage/storageModel.ts b/client/src/components/Admin/storage/storageModel.ts new file mode 100644 index 000000000..e7796edb7 --- /dev/null +++ b/client/src/components/Admin/storage/storageModel.ts @@ -0,0 +1,440 @@ +import { + STORAGE_CATEGORIES, + type StorageAdminState, + type StorageBackend, + type StorageCategory, + type StorageConfig, + type StorageConfigPut, +} from '@trek/shared' + +/** One entry of the effective world (GET state), as the panels render it. */ +export type StateBackend = StorageAdminState['backends'][number] + +/** + * The settings-owned document the PUT carries: settings-sourced backends and + * settings-sourced category assignments ONLY — built-ins and env backends are + * never in the body unless the operator overrides one by name (in which case + * the override already reports source 'settings'). Carries `version` from + * the state it was built at (audit #7 — optimistic concurrency): the hook + * re-attaches this same version on every subsequent local edit, so a PUT + * always submits the version the operator's form was loaded at, not + * whatever the server currently holds. + */ +export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut { + return { + backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend), + categories: Object.fromEntries( + Object.entries(state.categories) + .filter(([, entry]) => entry.source === 'settings') + .map(([category, entry]) => [category, entry.backend]), + ) as StorageConfig['categories'], + version: state.version, + } +} + +/** + * State options are already schema-shaped (the wire contract guarantees it; + * secret fields carry the mask, which the server unmasks by name on PUT/test) — + * the cast re-attaches the discriminated-union type the loose state record dropped. + */ +export function asWireBackend(backend: Pick): StorageBackend { + return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend +} + +/** Draft categories pointing at a backend name (the friendly remove pre-check). */ +export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] { + return (Object.entries(draft.categories) as Array<[StorageCategory, string]>) + .filter(([, backend]) => backend === name) + .map(([category]) => category) +} + +/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */ +export function mirrorsReferencing(draft: StorageConfig, name: string): string[] { + return draft.backends + .filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name))) + .map((b) => b.name) +} + +/** Replace by name in place, or append. Never mutates. */ +export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig { + const exists = draft.backends.some((b) => b.name === backend.name) + return { + ...draft, + backends: exists + ? draft.backends.map((b) => (b.name === backend.name ? backend : b)) + : [...draft.backends, backend], + } +} + +export function removeBackend(draft: StorageConfig, name: string): StorageConfig { + return { ...draft, backends: draft.backends.filter((b) => b.name !== name) } +} + +// ── Mirror fold/synthesize (replicas-on-primary spec) ───────────────────────── +// The draft stays the wire document; folding is a view rule, synthesis a set +// of pure draft operations. Mirror NAMES never reach the UI — every helper +// that renders phrases mirrors as their primary. + +type MirrorBackend = Extract +const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror' + +export interface FoldedBackendRow { + name: string + type: 'local' | 's3' + source: 'built-in' | 'env' | 'settings' + backend: StorageBackend + /** Direct assignments ∪ assignments routed via the adopted mirror. */ + categories: StorageCategory[] + /** Adopted mirror's replicas — [] when unmirrored. */ + mirrorTargets: string[] + /** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */ + mirrorName: string | null + /** Primaries whose adopted mirror lists this backend as a replica. */ + replicaOf: string[] +} + +export interface DegenerateMirror { + backend: StorageBackend + reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary' + categories: StorageCategory[] +} + +/** + * Re-fetchable content — replicating it is usually wasteful. photos-google is + * the Google photo cache (place-photo-cache.service.ts); places is mostly + * provider-derived imagery. + */ +export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places'] + +/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */ +export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record { + const map = {} as Record + for (const category of STORAGE_CATEGORIES) { + // The admin state's category record is exhaustive by schema contract. + map[category] = draft.categories[category] ?? state.categories[category]!.backend + } + return map +} + +/** First draft mirror wrapping the named primary — the one the UI manages. */ +export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined { + return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName) +} + +/** + * Client-side mirror expansion for the test probe (audit fix — the server's + * `testBackend` resolves a mirror candidate's primary/replicas against its + * OWN live snapshot by name, so probing a mirrored row with unsaved draft + * edits to those targets would silently test the SAVED options instead of + * what's on the form). Draft overrides win per name, falling back to state + * (built-in/env); a name that resolves to neither is dropped — that + * primary/replica no longer exists, and the row already renders as + * degenerate in that case. + */ +export function mirrorProbeTargets( + draft: StorageConfig, + state: StorageAdminState, + mirror: StorageBackend, +): StorageBackend[] { + if (mirror.type !== 'mirror') return [mirror] + const names = [mirror.options.primary, ...mirror.options.replicas] + return names + .map((name): StorageBackend | null => { + const draftMatch = draft.backends.find((b) => b.name === name) + if (draftMatch) return draftMatch + const stateMatch = state.backends.find((b) => b.name === name) + return stateMatch ? asWireBackend(stateMatch) : null + }) + .filter((b): b is StorageBackend => b !== null) +} + +export function foldBackends( + state: StorageAdminState, + draft: StorageConfig, +): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } { + const draftNames = draft.backends.map((b) => b.name) + const effective = effectiveCategoryMap(state, draft) + const categoriesOf = (name: string): StorageCategory[] => + STORAGE_CATEGORIES.filter((category) => effective[category] === name) + + // Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state + // unless a draft row overrides the name; settings rows from the draft. + const base: Array> = [] + for (const b of state.backends) { + if (b.type === 'mirror') continue // mirrors fold below; the draft owns them + if (draftNames.includes(b.name)) continue + if (b.source === 'settings') continue // removed from the draft, pending save + base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) }) + } + for (const b of draft.backends) { + if (isMirror(b)) continue + base.push({ name: b.name, type: b.type, source: 'settings', backend: b }) + } + + // Adoption: the first mirror per editable primary; the rest are degenerate — + // rendered unfolded with a reason, never silently dropped. + const adopted = new Map() + const degenerate: DegenerateMirror[] = [] + for (const mirror of draft.backends.filter(isMirror)) { + const primary = base.find((row) => row.name === mirror.options.primary) + if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) }) + else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) }) + else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) }) + else adopted.set(primary.name, mirror) + } + + const rows = base.map((row): FoldedBackendRow => { + const mirror = adopted.get(row.name) + return { + ...row, + categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])], + mirrorTargets: mirror ? [...mirror.options.replicas] : [], + mirrorName: mirror?.name ?? null, + replicaOf: [...adopted.values()] + .filter((m) => m.options.replicas.includes(row.name)) + .map((m) => m.options.primary), + } + }) + return { rows, degenerate } +} + +/** + * The rows offerable as mirror replicas (the "Mirror targets" picker). + * + * A backend that already serves a category — directly, or as the primary of a + * mirror a category routes to, which is exactly what `row.categories` unions — + * can never also be a replica: the mirror's sync sweep makes each replica + * MATCH the primary, deleting whatever the primary doesn't hold, and `backups` + * sweeps the replica's entire root. The server refuses such a config outright + * (storage-registry.service.ts, assertNoSharedReplicas); the picker simply + * never offers one, so the operator can't walk into the refusal. + * + * Targets already selected on this mirror stay listed regardless — they must + * remain visible to be unchecked (and a config that predates this rule is + * repaired here, not hidden). + */ +export function replicaCandidates( + rows: FoldedBackendRow[], + selfName: string | null, + currentTargets: readonly string[] = [], +): string[] { + return rows + .filter((row) => row.name !== selfName) + .filter((row) => row.categories.length === 0 || currentTargets.includes(row.name)) + .map((row) => row.name) +} + +function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string { + const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)]) + const base = `${primaryName}-mirror` + if (!taken.has(base)) return base + for (let n = 2; ; n++) { + if (!taken.has(`${base}-${n}`)) return `${base}-${n}` + } +} + +/** + * targets non-empty: update the adopted mirror in place (or synthesize one) + * and route every category that effectively resolves to the primary — + * default-sourced included — through it. targets empty: dissolve, re-pointing + * the mirror's categories at the primary and dropping entries that revert to + * their built-in default. + */ +export function setMirrorTargets( + state: StorageAdminState, + draft: StorageConfig, + primaryName: string, + targets: string[], +): StorageConfig { + const adopted = adoptedMirrorFor(draft, primaryName) + + if (targets.length === 0) { + if (!adopted) return draft + const categories = { ...draft.categories } + for (const category of STORAGE_CATEGORIES) { + if (categories[category] !== adopted.name) continue + const stateEntry = state.categories[category]! // exhaustive by schema contract + if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category] + else categories[category] = primaryName + } + return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories } + } + + const effective = effectiveCategoryMap(state, draft) + const mirror: StorageBackend = { + name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName), + type: 'mirror', + options: { primary: primaryName, replicas: [...targets] }, + } + const backends = adopted + ? draft.backends.map((b) => (b.name === adopted.name ? mirror : b)) + : [...draft.backends, mirror] + const categories = { ...draft.categories } + for (const category of STORAGE_CATEGORIES) { + if (effective[category] === primaryName) categories[category] = mirror.name + } + return { backends, categories } +} + +/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */ +export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig { + return { + backends: draft.backends.map((b) => { + if (!isMirror(b)) return b + return { + ...b, + options: { + primary: b.options.primary === oldName ? newName : b.options.primary, + replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)), + }, + } + }), + categories: Object.fromEntries( + Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]), + ) as StorageConfig['categories'], + } +} + +/** + * Remove a backend, every mirror wrapping it as primary, and every replica + * reference to it in the remaining mirrors — a mirror stripped of its last + * replica dissolves (with the same category re-pointing as an explicit + * dissolve), so the draft never carries a phantom reference the composer + * cannot render or repair. + */ +export function removeBackendAndMirrors( + state: StorageAdminState, + draft: StorageConfig, + name: string, +): StorageConfig { + const stripped = draft.backends + .filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name)) + .map((b) => + isMirror(b) && b.options.replicas.includes(name) + ? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } } + : b, + ) + let next: StorageConfig = { ...draft, backends: stripped } + for (const mirror of stripped.filter(isMirror)) { + if (mirror.options.replicas.length > 0) continue + if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) { + next = setMirrorTargets(state, next, mirror.options.primary, []) + } else { + // A degenerate duplicate stripped empty: drop it and re-point its categories. + const categories = { ...next.categories } + for (const category of STORAGE_CATEGORIES) { + if (categories[category] === mirror.name) categories[category] = mirror.options.primary + } + next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories } + } + } + return next +} + +/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */ +export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] { + return draft.backends + .filter(isMirror) + .filter((m) => m.options.replicas.includes(name)) + .map((m) => m.options.primary) +} + +/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */ +export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string { + const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName) + if (draftMirror) return draftMirror.options.primary + const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror') + if (stateMirror) return String((stateMirror.options as Record).primary) + return backendName +} + +/** + * Usage sums per VISIBLE backend row: each category's numbers land on the row + * that serves it (a mirror-routed category on the mirror's PRIMARY row), and + * the legacy photos directory lands on uploads-local — the only backend the + * served-legacy `photos` category ever resolves to. + */ +export function usageByBackend( + state: StorageAdminState, + draft: StorageConfig, +): Record | null { + if (!state.usage) return null + const sums: Record = {} + const add = (row: string, entry: { objects: number; bytes: number }) => { + const current = (sums[row] ??= { objects: 0, bytes: 0 }) + current.objects += entry.objects + current.bytes += entry.bytes + } + const effective = effectiveCategoryMap(state, draft) + for (const category of STORAGE_CATEGORIES) { + const entry = state.usage.categories[category] + if (entry) add(primaryNameOf(state, draft, effective[category]), entry) + } + // The served-legacy `photos` directory always resolves to uploads-local — + // hardcoded, not derived, because it is not one of the configurable categories. + add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos) + return sums +} + +// ── Category migration (copy → flip → delta sweep) ──────────────────────────── + +/** One reassigned category the operator might want to move existing objects for. */ +export interface MigrationCandidate { + category: StorageCategory + from: string + /** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */ + to: string + /** + * The RAW effective draft backend name for this category — possibly a + * mirror's wire name when `to` is a mirrored primary. The migration POST + * must carry this, not `to`: posting the bare primary would silently drop + * replication for a category that was just routed onto a mirror. + */ + toWire: string + /** null when usage was never scanned — the prompt still fires, with an unknown-size line. */ + objects: number | null + bytes: number | null +} + +/** + * Every category whose effective (draft-over-state) PRIMARY backend differs + * from what's currently saved — normalized through `primaryNameOf` so simply + * wrapping (or unwrapping) a mirror around the same primary never counts as + * a candidate; mirroring is the existing sync/backfill flow, not a category + * migration. Zero-object categories (usage present, 0 objects) are excluded — + * nothing to move; null usage (never scanned) keeps the category as a + * candidate with null counts, per spec ("unknown still prompts"). + */ +export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] { + const effective = effectiveCategoryMap(state, draft) + const candidates: MigrationCandidate[] = [] + for (const category of STORAGE_CATEGORIES) { + const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract + const toWire = effective[category] + const to = primaryNameOf(state, draft, toWire) + if (from === to) continue + const usage = state.usage?.categories[category] + candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null }) + } + return candidates.filter((c) => c.objects !== 0) +} + +/** + * Revert exactly the named categories in `draft` back to what's currently + * saved in `saved` — same default/settings convention as the rest of this + * file (drop the override when the saved entry is default-sourced, write the + * explicit backend name otherwise). Every other draft edit is untouched. + */ +export function stripCategories( + draft: StorageConfig, + saved: StorageAdminState, + categories: StorageCategory[], +): StorageConfig { + const next = { ...draft.categories } + for (const category of categories) { + const savedEntry = saved.categories[category]! // exhaustive by schema contract + if (savedEntry.source === 'default') delete next[category] + else next[category] = savedEntry.backend + } + return { ...draft, categories: next } +} diff --git a/client/src/components/Admin/storage/useStorageAdmin.ts b/client/src/components/Admin/storage/useStorageAdmin.ts new file mode 100644 index 000000000..880122493 --- /dev/null +++ b/client/src/components/Admin/storage/useStorageAdmin.ts @@ -0,0 +1,349 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared' +import { adminApi } from '../../../api/client' +import { getApiErrorMessage, type ApiError } from '../../../types' +import { settingsDocumentOf } from './storageModel' + +export type StorageTestResults = Record + +/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */ +export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000 + +/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */ +function isConflictError(err: unknown): boolean { + return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409 +} + +export interface StorageAdmin { + state: StorageAdminState | null + /** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */ + draft: StorageConfigPut | null + dirty: boolean + loading: boolean + loadError: string | null + /** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */ + saveError: string | null + /** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */ + saveConflict: boolean + saving: boolean + testResults: StorageTestResults + setDraft: (next: StorageConfig) => void + /** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */ + save: (overrideDraft?: StorageConfig) => Promise + test: (backend: StorageBackend) => Promise + /** + * Probe each target individually (client-expanded mirror primary + + * replicas — see `mirrorProbeTargets`) and merge the results into one + * `StorageTestResponse` under `key` (the row's mirror wire name). Never + * sends the mirror stub itself — each call probes a concrete backend. + */ + testMirror: (key: string, targets: StorageBackend[]) => Promise + /** null on success; the verbatim server error otherwise. */ + startBackfill: (mirrorName: string) => Promise + cancelBackfill: (mirrorName: string) => Promise + startMigration: (category: string, to: string) => Promise + cancelMigration: (category: string) => Promise + refreshStats: () => Promise + /** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */ + refreshState: () => Promise + /** + * The 409's only exit: throw the local edits away and reload the saved + * settings (fresh GET → fresh draft → fresh version), clearing dirty and + * saveError. See the implementation comment for why refreshState can't do + * this itself. + */ + discardDraft: () => Promise +} + +/** + * Shared by the desktop and phone storage panels (DefaultUserSettingsTab + * model: self-contained, adminApi directly, deliberately no offline core — + * hoster-level config is online-only). + */ +export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin { + const [state, setState] = useState(null) + const [draft, setDraftState] = useState(null) + const [dirty, setDirty] = useState(false) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + const [saveError, setSaveError] = useState(null) + const [saveConflict, setSaveConflict] = useState(false) + const [saving, setSaving] = useState(false) + const [testResults, setTestResults] = useState({}) + + // Read imperatively from the poll effect/refreshState so a stale closure + // never re-derives the draft out from under an in-flight edit. + const dirtyRef = useRef(dirty) + useEffect(() => { + dirtyRef.current = dirty + }, [dirty]) + + // Read imperatively at refreshState's resolve time so a poll GET that was + // in flight when a save started never applies after the save's own state. + const savingRef = useRef(saving) + useEffect(() => { + savingRef.current = saving + }, [saving]) + + // Bumped by every deliberate state application (initial load, save + // success). A refreshState() call captures the seq before its GET and + // drops the response if the seq moved on — i.e. a save applied its own + // fresher state — while that GET was in flight. + const stateSeq = useRef(0) + + const applyState = useCallback((next: StorageAdminState) => { + stateSeq.current += 1 + setState(next) + setDraftState(settingsDocumentOf(next)) + setDirty(false) + setSaveError(null) + setSaveConflict(false) + }, []) + + useEffect(() => { + let cancelled = false + adminApi + .getStorage() + .then((data) => { + if (!cancelled) applyState(data) + }) + .catch((err: unknown) => { + if (!cancelled) setLoadError(getApiErrorMessage(err, genericError)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [applyState, genericError]) + + // Re-attaches the CURRENT draft's version onto whatever shape the caller + // hands in (the storageModel edit helpers are version-blind — they operate + // on plain StorageConfig — so this is the one place that keeps `version` + // pinned to what the operator's form was loaded at, no matter how many + // local edits run in between). + const setDraft = useCallback((next: StorageConfig) => { + setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 })) + setDirty(true) + }, []) + + // Poll-safe refresh: replaces `state` unconditionally, but only re-derives + // `draft` when the operator has no unsaved edits in flight — never touches + // `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or + // mask a pending save error. + // + // Race guard: a poll GET issued before a save's PUT resolves can resolve + // AFTER the save applied its own (fresher) response, overwriting it with + // the pre-save world. Capture the state-application sequence number before + // the await and drop the response if it moved on, or if a save is + // in-flight/just landed (read via a ref so it's current at resolve time). + // + // Declared before `save` (which calls it on a 409) — a hook's `const`s + // must exist before another callback's dependency array references them. + const refreshState = useCallback(async (): Promise => { + const seq = stateSeq.current + const next = await adminApi.getStorage() + if (stateSeq.current !== seq || savingRef.current) return + setState(next) + if (!dirtyRef.current) setDraftState(settingsDocumentOf(next)) + }, []) + + const save = useCallback( + async (overrideDraft?: StorageConfig): Promise => { + if (!draft) return false + // Always the DRAFT's own version, even for an overrideDraft (a variant + // of the same draft with categories stripped, per stripCategories) — + // both share the same version basis: what the operator's form was + // loaded/refreshed at, never re-read from `state` here (that would + // defeat the whole check — see setDraft's re-attach comment above). + const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version } + setSaving(true) + setSaveError(null) + setSaveConflict(false) + try { + // The response is the fresh effective world, never the request echo. + applyState(await adminApi.updateStorage(body)) + return true + } catch (err: unknown) { + if (isConflictError(err)) { + // Something else (another admin's save, or a category migration's + // flip) wrote since this draft's version was captured. Refresh + // `state` so the operator sees the fresh world, but the dirty + // draft itself is left untouched for review (dirty is never + // cleared here) — refreshState() already respects dirtyRef. + setSaveError(conflictError) + setSaveConflict(true) + void refreshState().catch(() => {}) + } else { + setSaveError(getApiErrorMessage(err, genericError)) + } + return false + } finally { + setSaving(false) + } + }, + [draft, applyState, genericError, conflictError, refreshState], + ) + + /** + * Conflict recovery (final-review fix): after a 409 the dirty draft keeps + * the version it was built at. refreshState deliberately leaves a dirty + * draft's version alone — bumping it there would resurrect audit #7, where + * the background poll floats a mid-edit draft past a migration flip it + * never carried — so nothing else can ever move it, and every retry 409s + * again. The operator's way out is to say so explicitly: discard the local + * edits, reload the saved settings, and start over from the fresh version. + * A fresh GET (not the possibly-dropped post-409 refresh) guarantees that + * version is current, and applyState clears dirty/saveError with it. + */ + const discardDraft = useCallback(async (): Promise => { + try { + applyState(await adminApi.getStorage()) + } catch (err: unknown) { + setSaveError(getApiErrorMessage(err, genericError)) + setSaveConflict(false) + } + }, [applyState, genericError]) + + const test = useCallback( + async (backend: StorageBackend): Promise => { + setTestResults((prev) => ({ ...prev, [backend.name]: 'running' })) + try { + const result = await adminApi.testStorageBackend(backend) + setTestResults((prev) => ({ ...prev, [backend.name]: result })) + } catch (err: unknown) { + const error = getApiErrorMessage(err, genericError) + setTestResults((prev) => ({ + ...prev, + [backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] }, + })) + } + }, + [genericError], + ) + + const testMirror = useCallback( + async (key: string, targets: StorageBackend[]): Promise => { + setTestResults((prev) => ({ ...prev, [key]: 'running' })) + const settled = await Promise.all( + targets.map(async (target) => { + try { + const result = await adminApi.testStorageBackend(target) + return result.targets[0] ?? { name: target.name, ok: result.ok } + } catch (err: unknown) { + return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) } + } + }), + ) + setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } })) + }, + [genericError], + ) + + // While any backfill or category migration is running, poll GET state so + // progress/counts advance without the operator refreshing the page. + useEffect(() => { + if ( + !state?.backfills.some((b) => b.status === 'running') && + !state?.migrations.some((m) => m.status === 'running') + ) + return + const iv = setInterval(() => { + // A transient poll failure is retried on the next tick — it must never + // surface as a toast or interrupt the operator's in-progress edit. + void refreshState().catch(() => {}) + }, BACKFILL_POLL_MS) + return () => clearInterval(iv) + }, [state, refreshState]) + + const startBackfill = useCallback( + async (mirrorName: string): Promise => { + try { + await adminApi.startStorageBackfill(mirrorName) + void refreshState().catch(() => {}) + return null + } catch (err: unknown) { + return getApiErrorMessage(err, genericError) + } + }, + [refreshState, genericError], + ) + + const cancelBackfill = useCallback( + async (mirrorName: string): Promise => { + try { + await adminApi.cancelStorageBackfill(mirrorName) + void refreshState().catch(() => {}) + return null + } catch (err: unknown) { + return getApiErrorMessage(err, genericError) + } + }, + [refreshState, genericError], + ) + + const startMigration = useCallback( + async (category: string, to: string): Promise => { + try { + await adminApi.startStorageMigration(category, to) + // Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the + // queue effect's in-flight lock releases as soon as this promise + // resolves, so admin.state must already reflect the just-started + // migration by then — otherwise the next queued candidate can + // dequeue and POST before the server has confirmed this one. + await refreshState().catch(() => {}) + return null + } catch (err: unknown) { + return getApiErrorMessage(err, genericError) + } + }, + [refreshState, genericError], + ) + + const cancelMigration = useCallback( + async (category: string): Promise => { + try { + await adminApi.cancelStorageMigration(category) + void refreshState().catch(() => {}) + return null + } catch (err: unknown) { + return getApiErrorMessage(err, genericError) + } + }, + [refreshState, genericError], + ) + + const refreshStats = useCallback(async (): Promise => { + try { + const usage = await adminApi.refreshStorageStats() + setState((prev) => (prev ? { ...prev, usage } : prev)) + return null + } catch (err: unknown) { + return getApiErrorMessage(err, genericError) + } + }, [genericError]) + + return { + state, + draft, + dirty, + loading, + loadError, + saveError, + saveConflict, + saving, + testResults, + setDraft, + save, + test, + testMirror, + startBackfill, + cancelBackfill, + startMigration, + cancelMigration, + refreshStats, + refreshState, + discardDraft, + } +} diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx new file mode 100644 index 000000000..c8ad05d7b --- /dev/null +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -0,0 +1,278 @@ +// FE-W4BGT-001 to FE-W4BGT-022 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { screen, act, waitFor } from '@testing-library/react' +import { render, fireEvent } from '../../../tests/helpers/render' +import { reservationsApi, healthApi } from '../../api/client' +import { addListener } from '../../api/websocket' +import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' +import BackgroundTasksWidget from './BackgroundTasksWidget' + +const navigate = vi.fn() +vi.mock('react-router', async () => { + const actual = await vi.importActual('react-router') + return { ...actual, useNavigate: () => navigate } +}) +vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() })) +vi.mock('../../api/client', () => ({ + reservationsApi: { importJobStatus: vi.fn(), importBookingAsync: vi.fn() }, + healthApi: { features: vi.fn() }, +})) +vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) })) + +const task = (overrides: Partial = {}): BackgroundImportTask => ({ + id: 'j1', tripId: 't1', label: 'voucher.pdf', status: 'done', done: 0, total: 1, items: [], warnings: [], + ...overrides, +}) + +type WsHandler = (e: Record) => void + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {})) + vi.mocked(reservationsApi.importJobStatus).mockReturnValue(new Promise(() => {})) + useBackgroundTasksStore.setState({ tasks: [] }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('BackgroundTasksWidget — rendering', () => { + it('FE-W4BGT-001: renders nothing without tasks', () => { + const { container, baseElement } = render() + + expect(container).toBeEmptyDOMElement() + expect(baseElement.querySelectorAll('button')).toHaveLength(0) + }) + + it('FE-W4BGT-002: a running job shows the spinner, the parsing note and no close button', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 1, total: 3 })] }) + const { baseElement } = render() + + expect(screen.getByText('voucher.pdf')).toBeInTheDocument() + expect(screen.getByText(/· 1\/3$/)).toBeInTheDocument() + expect(baseElement.querySelector('.animate-spin')).not.toBeNull() + expect(screen.queryByLabelText('Close')).toBeNull() + }) + + it('FE-W4BGT-003: a single-file job omits the counter', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 0, total: 1 })] }) + render() + + expect(screen.queryByText(/·/)).toBeNull() + }) + + it('FE-W4BGT-004: a restored done job without items still reads as parsing', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'done', items: undefined })] }) + const { baseElement } = render() + + expect(baseElement.querySelector('.animate-spin')).not.toBeNull() + expect(screen.getByLabelText('Close')).toBeInTheDocument() + }) + + it('FE-W4BGT-005: a finished job with items offers the review action', () => { + useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never })] }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Import' })) + + expect(useBackgroundTasksStore.getState().tasks[0].reviewRequested).toBe(true) + expect(navigate).toHaveBeenCalledWith('/trips/t1') + }) + + it('FE-W4BGT-006: a failed job shows the error message', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'error', error: 'AI quota exhausted' })] }) + render() + + expect(screen.getByText('AI quota exhausted')).toBeInTheDocument() + }) + + it('FE-W4BGT-007: the close button drops the card', () => { + useBackgroundTasksStore.setState({ tasks: [task()] }) + render() + + fireEvent.click(screen.getByLabelText('Close')) + + expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0) + }) +}) + +describe('BackgroundTasksWidget — websocket', () => { + it('FE-W4BGT-008: import:progress updates the running card', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] }) + render() + const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler + + act(() => { handler({ type: 'import:progress', jobId: 'j1', tripId: 't1', done: 2, total: 4 }) }) + + expect(screen.getByText(/· 2\/4$/)).toBeInTheDocument() + }) + + it('FE-W4BGT-009: import:done attaches the parsed items', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + render() + const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler + + act(() => { handler({ type: 'import:done', jobId: 'j1', tripId: 't1', result: { items: [{ id: 1 }], warnings: [] } }) }) + + expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument() + }) + + it('FE-W4BGT-010: import:error surfaces the message', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running' })] }) + render() + const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler + + act(() => { handler({ type: 'import:error', jobId: 'j1', tripId: 't1', message: 'boom' }) }) + + expect(screen.getByText('boom')).toBeInTheDocument() + }) + + it('FE-W4BGT-011: unrelated events and events without a job id are ignored', () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] }) + render() + const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler + + act(() => { + handler({ type: 'place:updated', jobId: 'j1' }) + handler({ type: 'import:progress', done: 3, total: 4 }) + handler({ done: 3 }) + }) + + expect(screen.getByText(/· 0\/4$/)).toBeInTheDocument() + }) +}) + +describe('BackgroundTasksWidget — rehydrate', () => { + it('FE-W4BGT-012: a restored job that the server finished gets its items back', async () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ + status: 'done', done: 1, total: 1, result: { items: [{ id: 1 }], warnings: [] }, + } as never) + + render() + + expect(await screen.findByRole('button', { name: 'Import' })).toBeInTheDocument() + expect(reservationsApi.importJobStatus).toHaveBeenCalledWith('t1', 'j1') + }) + + it('FE-W4BGT-013: a restored job the server reports as failed shows the error', async () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ status: 'error', error: 'expired', done: 0, total: 1 } as never) + + render() + + expect(await screen.findByText('expired')).toBeInTheDocument() + }) + + it('FE-W4BGT-014: a restored job the server has dropped is removed', async () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 404 } }) + + render() + + await waitFor(() => expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0)) + }) + + it('FE-W4BGT-015: a non-404 failure keeps the card', async () => { + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 500 } }) + + render() + + await waitFor(() => expect(reservationsApi.importJobStatus).toHaveBeenCalled()) + expect(useBackgroundTasksStore.getState().tasks).toHaveLength(1) + }) +}) + +describe('BackgroundTasksWidget — poll backstop', () => { + it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 404 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error') + // The file parsed fine — blaming it would send the user looking for a problem + // that isn't there. + expect(useBackgroundTasksStore.getState().tasks[0].error).toBe('Unknown error') + }) + + it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => { + vi.useFakeTimers() + useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] }) + vi.mocked(reservationsApi.importJobStatus) + .mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never) + .mockRejectedValue({ response: { status: 500 } }) + + render() + await act(async () => { await vi.advanceTimersByTimeAsync(5000) }) + + expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running') + }) +}) + +describe('BackgroundTasksWidget — AI retry', () => { + const withFiles = () => task({ + items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })], + }) + + it('FE-W4BGT-016: offers the AI retry only when the feature is on', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never) + useBackgroundTasksStore.setState({ tasks: [withFiles()] }) + + render() + + expect(await screen.findByRole('button', { name: /AI/i })).toBeInTheDocument() + }) + + it('FE-W4BGT-017: hides the retry when the feature probe fails', async () => { + vi.mocked(healthApi.features).mockRejectedValue(new Error('down')) + useBackgroundTasksStore.setState({ tasks: [withFiles()] }) + + render() + + await waitFor(() => expect(healthApi.features).toHaveBeenCalled()) + expect(screen.queryByRole('button', { name: /AI/i })).toBeNull() + }) + + it('FE-W4BGT-018: hides the retry on a job that already ran with force-ai', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never) + useBackgroundTasksStore.setState({ tasks: [task({ items: [], mode: 'force-ai', sourceFiles: [new File([''], 'a.pdf')] })] }) + + render() + + await waitFor(() => expect(healthApi.features).toHaveBeenCalled()) + expect(screen.queryByRole('button', { name: /AI/i })).toBeNull() + }) + + it('FE-W4BGT-019: retrying swaps the card for the new force-ai job', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never) + vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' } as never) + useBackgroundTasksStore.setState({ tasks: [withFiles()] }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /AI/i })) + + await waitFor(() => { + const tasks = useBackgroundTasksStore.getState().tasks + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'j2', mode: 'force-ai', tripId: 't1' }) + }) + }) + + it('FE-W4BGT-020: a refused retry surfaces the server error on the original card', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never) + vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No model configured' } } }) + useBackgroundTasksStore.setState({ tasks: [withFiles()] }) + render() + + fireEvent.click(await screen.findByRole('button', { name: /AI/i })) + + expect(await screen.findByText('No model configured')).toBeInTheDocument() + }) +}) diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx index 3883a5f5e..a8584af6d 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.test.tsx @@ -1,14 +1,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { screen } from '@testing-library/react' +import { screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { render } from '../../../tests/helpers/render' +import { reservationsApi, healthApi } from '../../api/client' +import { saveImportFiles } from '../../db/offlineDb' import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' import BackgroundTasksWidget from './BackgroundTasksWidget' vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() })) vi.mock('../../api/client', () => ({ // Keep the rehydrate/poll backstops pending so the seeded state is what renders. - reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})) }, + reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})), importBookingAsync: vi.fn() }, + healthApi: { features: vi.fn() }, })) +vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) })) const task = (overrides: Partial = {}): BackgroundImportTask => ({ id: 'j1', @@ -22,8 +27,14 @@ const task = (overrides: Partial = {}): BackgroundImportTa ...overrides, }) +const pdf = () => new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' }) + beforeEach(() => { vi.clearAllMocks() + // Like the poll backstop above: leave the feature probe pending so tests that don't care + // about the AI retry render the same widget they did before the button existed. + vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {})) + vi.mocked(saveImportFiles).mockResolvedValue(undefined) useBackgroundTasksStore.setState({ tasks: [] }) }) @@ -42,4 +53,101 @@ describe('BackgroundTasksWidget', () => { expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument() }) + + describe('AI retry', () => { + it('offers the retry on an empty result once the addon reports AI parsing', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true }) + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] }) + render() + expect(await screen.findByRole('button', { name: 'Try AI parsing' })).toBeInTheDocument() + }) + + it('stays hidden when the addon is off, the files are gone, or the run was already force-ai', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: false }) + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] }) + const { unmount } = render() + await waitFor(() => expect(healthApi.features).toHaveBeenCalled()) + expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument() + unmount() + + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true }) + // Rehydrated from storage: sourceFiles can't survive a reload, so there is nothing to resend. + useBackgroundTasksStore.setState({ tasks: [task()] }) + const withoutFiles = render() + await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(2)) + expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument() + withoutFiles.unmount() + + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()], mode: 'force-ai' })] }) + render() + await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(3)) + expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument() + }) + + it('re-submits the files with force-ai, keeps them for the review and replaces the task', async () => { + const file = pdf() + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true }) + vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' }) + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [file] })] }) + render() + + await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' })) + + expect(reservationsApi.importBookingAsync).toHaveBeenCalledWith('t1', [file], 'force-ai') + // Without this the reviewed bookings lose their source document after a reload. + await waitFor(() => expect(saveImportFiles).toHaveBeenCalledWith('j2', [file])) + await waitFor(() => { + const tasks = useBackgroundTasksStore.getState().tasks + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'j2', tripId: 't1', status: 'running', mode: 'force-ai' }) + }) + }) + + it('keeps the task and surfaces the server error when the retry is rejected', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true }) + vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No AI model configured' } } }) + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] }) + render() + + await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' })) + + expect(await screen.findByText('No AI model configured')).toBeInTheDocument() + const tasks = useBackgroundTasksStore.getState().tasks + expect(tasks).toHaveLength(1) + expect(tasks[0]).toMatchObject({ id: 'j1', status: 'error' }) + expect(saveImportFiles).not.toHaveBeenCalled() + }) + + it('ignores a second click while the first retry is still in flight', async () => { + vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true }) + // Never settles: the retry stays in flight for the whole test. + vi.mocked(reservationsApi.importBookingAsync).mockReturnValue(new Promise<{ jobId: string }>(() => {})) + useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] }) + render() + + const button = await screen.findByRole('button', { name: 'Try AI parsing' }) + await userEvent.click(button) + await waitFor(() => expect(button).toBeDisabled()) + await userEvent.click(button) + + expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1) + }) + }) + + /* + * Warnings used to appear only when nothing at all was found, so a partly + * understood import dropped whatever it could not place without a word — a + * station the geocoder could not locate, say (#1969), whose endpoint then + * disappeared on save. + */ + it('shows the warnings on a job that did produce items', () => { + const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving' + useBackgroundTasksStore.setState({ + tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })], + }) + render() + expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument() + // and the import button is still there — the job did find something + expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument() + }) }) diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 977833e4e..55ec57249 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -1,10 +1,11 @@ -import ReactDOM from 'react-dom' -import { useEffect, useRef } from 'react' -import { useNavigate } from 'react-router-dom' +import { createPortal } from 'react-dom' +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router' import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { addListener, removeListener } from '../../api/websocket' -import { reservationsApi } from '../../api/client' +import { reservationsApi, healthApi } from '../../api/client' +import { saveImportFiles } from '../../db/offlineDb' import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore' /** @@ -23,6 +24,34 @@ export default function BackgroundTasksWidget() { const setError = useBackgroundTasksStore((s) => s.setError) const requestReview = useBackgroundTasksStore((s) => s.requestReview) const dismiss = useBackgroundTasksStore((s) => s.dismiss) + const addTask = useBackgroundTasksStore((s) => s.addTask) + + const [aiParsing, setAiParsing] = useState(false) + useEffect(() => { + healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false)) + }, []) + + // Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped. + const [retrying, setRetrying] = useState(null) + const retryWithAi = async (task: BackgroundImportTask) => { + const files = task.sourceFiles + if (!files || files.length === 0 || retrying === task.id) return + setRetrying(task.id) + try { + const { jobId } = await reservationsApi.importBookingAsync(task.tripId, files, 'force-ai') + // Same as the modal's first submit: the review attaches each source document to the + // booking it created, and only IndexedDB survives a reload mid-parse. + await saveImportFiles(jobId, files) + dismiss(task.id) + addTask({ id: jobId, tripId: task.tripId, label: task.label, total: files.length, files, mode: 'force-ai' }) + } catch (err) { + // 409 when the addon is enabled but this user has no model configured. + const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error + setError(task.id, task.tripId, message ?? t('reservations.import.error')) + } finally { + setRetrying(null) + } + } // On (re)load, reconcile tasks restored from localStorage with the server: a parse // that was still running when the page reloaded must keep its widget, so re-fetch each @@ -82,11 +111,16 @@ export default function BackgroundTasksWidget() { else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error') else setProgress(task.id, task.tripId, s.done, s.total) }) - .catch(() => {}) + .catch((err: { response?: { status?: number } }) => { + // The server 404s a job once it has expired (or after a restart). Nothing is + // ever coming, so end the card instead of spinning and re-polling forever. + // Not the parse-failure message — the file was fine, the job is just gone. + if (err?.response?.status === 404) setError(task.id, task.tripId, t('common.unknownError')) + }) } }, 5000) return () => clearInterval(iv) - }, [tasks, setProgress, setDone, setError]) + }, [tasks, setProgress, setDone, setError, t]) if (tasks.length === 0) return null @@ -95,7 +129,7 @@ export default function BackgroundTasksWidget() { navigate(`/trips/${task.tripId}`) } - return ReactDOM.createPortal( + return createPortal(
@@ -128,22 +162,46 @@ export default function BackgroundTasksWidget() { // Restored from a reload; items are being re-fetched (see the poll backstop).
{t('reservations.import.parsing')}
) : task.items.length > 0 ? ( - - ) : ( -
- {t('reservations.import.previewEmpty')} +
+ + {/* A partly-understood import warns too. Warnings used to be + shown only when nothing at all was found, so anything the + parse could not place — a station it could not locate, say + (#1969) — was dropped without a word on exactly the imports + that did produce something. */} {(task.warnings?.length ?? 0) > 0 && ( -
+
{task.warnings!.join('\n')}
)}
+ ) : ( +
+
+ {t('reservations.import.previewEmpty')} + {(task.warnings?.length ?? 0) > 0 && ( +
+ {task.warnings!.join('\n')} +
+ )} +
+ {aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && ( + + )} +
) )} @@ -153,7 +211,7 @@ export default function BackgroundTasksWidget() {
{task.status !== 'running' && ( - @@ -87,7 +87,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro placeholder={t('budget.categoryName')} style={{ flex: 1, minWidth: 0, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', outline: 'none', fontFamily: 'inherit', background: 'var(--bg-card)', color: 'var(--text-primary)' }} /> -
)} -
-#### 🧭 Trip planning +#### 🧭 Planning -- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves -- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization -- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key) -- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files -- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering -- **Route optimisation** — auto-sort places and export to Google Maps -- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback -- **Category filter** — show only matching pins on the map +- **Day plans**: drag places between days and reorder inside a day, with undo. Notes and bookings drag the same way, and a map marker drops straight onto a day +- **Maps**: Leaflet, Mapbox GL or MapLibre GL (OpenFreeMap, no token), with clustering, photo markers and route lines. 3D buildings and terrain are Mapbox only +- **Place search**: Google Places when a key is set (photos, ratings, opening hours), otherwise OpenStreetMap with no key +- **Place enrichment**: descriptions, facts, hours and photo candidates from OpenStreetMap, Wikipedia, Wikidata and Wikimedia Commons +- **POI explore**: pull OpenStreetMap POIs by category for the current viewport over Overpass +- **Import**: shared Google Maps and Naver Maps lists, plus GPX, KML and KMZ files +- **Export**: GPX of a trip's places and tracks, and an ICS feed per trip or across all of them +- **Routes**: auto-sort a day (nearest neighbour then 2-opt, locked stops and hotel anchors stay put), driving, walking or cycling profiles over OSRM, then open it in Google Maps or CoMaps +- **Public transport**: door-to-door itineraries over Transitous +- **Weather**: 16-day forecast from Open-Meteo, no key. Dates outside that window read the archive for the same date instead +- **Day notes**: markdown body with an icon and a colour, reordered by drag and drop or moved to another day +- **Trip dates**: move a trip and the days re-date themselves, either dragging the bookings along or re-anchoring them. Trips also copy and archive -#### 🧳 Travel management +#### 🧳 Bookings and money -- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary)) -- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency -- **Packing lists** — categories, templates, user assignment, progress tracking -- **Bag tracking** — optional weight tracking with iOS-style distribution -- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each) -- **PDF export** — full trip plan as PDF with cover page, images, notes +- **Reservations**: 16 booking types with status, confirmation code, travellers and attached files +- **Flights and trains**: several legs with stopovers, per-leg times and endpoint timezones, against 4,045 bundled airports so local times resolve without a key +- **Accommodation**: a stay spans a range of days with a check-in window, and shows on every night it covers +- **Booking import**: EML, PDF, PKPass, HTML and TXT confirmations through [KItinerary](https://invent.kde.org/pim/kitinerary). Needs the `kitinerary-extractor` binary, which ships in the Docker image +- **AirTrail** (off by default): link a self-hosted AirTrail instance to import flights as reservations and keep them in sync +- **Costs**: split expenses in integer cents with equal or custom shares, several payers per expense, settle-up suggestions, a settlement log, and CSV export +- **Currencies**: a currency per expense with the rate frozen at entry. Rates come from Frankfurter, no key +- **Packing lists**: categories, admin-managed templates, assignees, three visibility tiers, and a packed-of-total line. Bags with weight roll-up are a separate admin switch, off by default +- **To-dos**: assignee, due date, priority, and a reminder before one falls due +- **Files**: attach to a trip, place, day entry or reservation. 50 MB each, 500 MB for video, with trash and restore +- **PDF export**: cover page, place photos, day notes, bookings and costs, with an optional page break per day
-#### 📱 Mobile & PWA +#### 🧩 Plugins -- **Installable** — iOS and Android, straight from the browser, no App Store needed -- **Offline support** — Service Worker caches tiles, API, uploads via Workbox -- **Native feel** — fullscreen standalone, themed status bar, splash screen -- **Touch optimised** — mobile-specific layouts with safe-area handling +- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/` +- **Sandboxed**: one child process per plugin, 63 grantable permissions, an admin-edited outbound host allowlist, memory and RPC caps, and daily caps on AI and notification calls +- **Trust**: registry downloads are pinned by sha256 and checked against the author's minisign key. A sideloaded zip is marked unverified, and `TREK_PLUGINS_ENABLED=false` turns the whole system off +- **Extension points**: map markers and layers, place details, day schedules, PDF sections, Atlas layers, journal rows, trip warnings, calendar sources, route profiles and notification channels. Plugin pages run in an opaque-origin iframe +- **SDK**: [`trek-plugin-sdk`](https://www.npmjs.com/package/trek-plugin-sdk) on npm, with a manifest validator, a mock host, and a dev-link mode that runs a local build against real data + +#### 🤖 AI and MCP + +- **MCP server** (off by default): OAuth 2.1 with mandatory PKCE and dynamic client registration. 199 tools, 30 resources, 4 prompts +- **Scopes**: 29 scopes in 14 groups, each one tickable on the consent screen. Tokens are bound to the `/mcp` resource, and every tool call lands in the audit log +- **Reach**: create trips and days, edit places, packing lists, to-dos, costs, reservations, collections and journeys, mark countries visited, all inside the scopes the token holds +- **Prompts**: `trip-summary`, plus `packing-list` and `budget-overview` when those addons are on +- **Addon-aware**: seven addon gates decide which tools and resources a session sees. Flipping one drops live sessions so the surface re-registers +- **Booking extraction** (off by default): read a confirmation with a local Ollama server, any OpenAI-compatible endpoint, or Anthropic, configured instance-wide or per user
-#### 🧩 Addons (admin-toggleable) +#### 📱 Mobile and offline -- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking -- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency -- **Documents** — file attachments on trips, places, and reservations -- **Collab** — chat, notes, polls, day-by-day attendance -- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking -- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI -- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods -- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations -- **MCP** — expose TREK to AI assistants via OAuth 2.1 +- **Installable**: iOS and Android straight from the browser, no App Store. Runs standalone without browser chrome, with the status bar tinted per theme +- **Phone layouts**: a separate shell below 768px with its own tokens, a bottom bar and safe-area insets. Which items sit in that bar is up to the user +- **Offline reads**: the app shell and every route chunk are precached, and trips, places and file blobs live in a per-user IndexedDB store, so a trip opens with no network +- **Offline writes**: mutations queue and replay with an `X-Idempotency-Key`, so a reconnect cannot double-apply. A stale edit is parked for a keep-mine or keep-theirs choice +- **Offline maps**: pre-download a trip's raster tiles, and clear them again +- **API responses are never cached** by the service worker. They vary per session, so they always come from the network -#### 🤖 AI / MCP +#### ⚙️ Admin, accounts and security -- **Built-in MCP server** — OAuth 2.1 authenticated. 150+ tools, 30 resources -- **Granular scopes** — 27 OAuth scopes across 13 permission groups -- **Full automation** — AI can create trips, plan days, build packing lists, manage budgets, mark countries visited -- **Pre-built prompts** — `trip-summary`, `packing-list`, `budget-overview` -- **Addon-aware** — exposes Atlas, Collab, Vacay when those addons are on - -
- -#### ⚙️ Admin & customisation - -- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar -- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR -- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history -- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center -- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates +- **Login methods**: password login, password registration, OIDC login, OIDC registration and passkey login each switch on and off on their own. `OIDC_ONLY` leaves SSO as the only way in +- **SSO (OIDC)**: one provider by discovery, with PKCE and `id_token` verification (Authentik, Keycloak, Google and the like) +- **2FA**: TOTP with ten single-use backup codes, and an admin can require it instance-wide +- **Passkeys**: WebAuthn login by fingerprint, face, PIN or security key, off until an admin enables it. A passkey also satisfies the 2FA requirement +- **Hardening**: per-IP limits on login, password reset and 2FA attempts, a password policy, secrets encrypted at rest and masked on read, and an SSRF guard on every URL you configure +- **Admin panel**: users and invites, the permission matrix, packing templates, categories, addons, plugins, API keys, MCP tokens and OAuth sessions, backups, storage, audit log, and GitHub releases +- **Backups**: manual or scheduled hourly, daily, weekly or monthly, with retention in days. The zip carries the at-rest key, so a restore can decrypt its own secrets +- **Storage**: pluggable storage backends per content category — keep everything on local disk, or add S3-compatible backends and replicate any category to them, configured entirely from the admin panel +- **Notifications**: a per-user matrix of events against in-app, email (SMTP), webhook and ntfy, plus any channel a plugin registers +- **Appearance**: light, dark or follow the OS, seven colour schemes plus a custom accent, transparency, compact density, reduce motion, and text size per tier +- **23 languages**: en, de, es, fr, it, nl, hu, ru, zh, zh-TW, pl, cs, ar (right to left), br, id, tr, ja, ko, uk, gr, sv, vi, ca +- **In-app help**: the wiki ships inside the image and is served from disk at `/help`, so the docs match the version you are running
) + return { onAdd, ...utils } +} + +const nameInput = () => screen.getByPlaceholderText('budget.newEntry') +const priceInput = () => screen.getByPlaceholderText('0,00') +const noteInput = () => screen.getByPlaceholderText('budget.table.note') +const numberInputs = () => screen.getAllByPlaceholderText('-') + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('BudgetPanelAddItemRow', () => { + it('FE-W4AIR-001: the add button stays disabled until a name is typed', () => { + setup() + const button = screen.getByRole('button', { name: 'reservations.add' }) + + expect(button).toBeDisabled() + fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) + expect(button).toBeEnabled() + }) + + it('FE-W4AIR-002: submits the trimmed name with parsed numbers', () => { + const { onAdd } = setup() + + fireEvent.change(nameInput(), { target: { value: ' Ferry ' } }) + fireEvent.change(priceInput(), { target: { value: '129,90' } }) + fireEvent.change(numberInputs()[0], { target: { value: '2' } }) + fireEvent.change(numberInputs()[1], { target: { value: '3' } }) + fireEvent.change(noteInput(), { target: { value: ' one way ' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) + + expect(onAdd).toHaveBeenCalledWith({ + name: 'Ferry', total_price: 129.9, persons: 2, days: 3, note: 'one way', expense_date: null, + }) + }) + + it('FE-W4AIR-003: falls back to zero price and null optionals', () => { + const { onAdd } = setup() + + fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) + fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) + + expect(onAdd).toHaveBeenCalledWith({ + name: 'Ferry', total_price: 0, persons: null, days: null, note: null, expense_date: null, + }) + }) + + it('FE-W4AIR-004: ignores a whitespace-only name', () => { + const { onAdd } = setup() + + fireEvent.change(nameInput(), { target: { value: ' ' } }) + fireEvent.keyDown(nameInput(), { key: 'Enter' }) + + expect(onAdd).not.toHaveBeenCalled() + }) + + it('FE-W4AIR-005: Enter in any field submits the row', () => { + const { onAdd } = setup() + fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) + + fireEvent.keyDown(priceInput(), { key: 'Enter' }) + expect(onAdd).toHaveBeenCalledTimes(1) + }) + + it('FE-W4AIR-006: a non-Enter key does not submit', () => { + const { onAdd } = setup() + fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) + + fireEvent.keyDown(nameInput(), { key: 'a' }) + + expect(onAdd).not.toHaveBeenCalled() + }) + + it('FE-W4AIR-007: clears the row and refocuses the name field after adding', () => { + setup() + fireEvent.change(nameInput(), { target: { value: 'Ferry' } }) + fireEvent.change(priceInput(), { target: { value: '12' } }) + + fireEvent.click(screen.getByRole('button', { name: 'reservations.add' })) + + expect(nameInput()).toHaveValue('') + expect(priceInput()).toHaveValue('') + vi.advanceTimersByTime(60) + expect(nameInput()).toHaveFocus() + }) + + it('FE-W4AIR-008: pasting a formatted amount normalizes the separators', () => { + setup() + + fireEvent.paste(priceInput(), { clipboardData: { getData: () => '1.234,56 EUR' } }) + expect(priceInput()).toHaveValue('1234.56') + + fireEvent.paste(priceInput(), { clipboardData: { getData: () => '$2,345.67' } }) + expect(priceInput()).toHaveValue('2345.67') + }) + + it('FE-W4AIR-009: pasting a separator-free amount keeps the digits', () => { + setup() + + fireEvent.paste(priceInput(), { clipboardData: { getData: () => 'EUR 4200' } }) + + expect(priceInput()).toHaveValue('4200') + }) +}) diff --git a/client/src/components/Budget/BudgetPanelAddItemRow.tsx b/client/src/components/Budget/BudgetPanelAddItemRow.tsx index 484cddaa2..f19e438d2 100644 --- a/client/src/components/Budget/BudgetPanelAddItemRow.tsx +++ b/client/src/components/Budget/BudgetPanelAddItemRow.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from 'react' import { Plus } from 'lucide-react' import { CustomDatePicker } from '../shared/CustomDateTimePicker' +import { normalizePastedAmount } from './BudgetPanel.helpers' interface AddItemRowProps { onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void @@ -18,7 +19,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) { const handleAdd = () => { if (!name.trim()) return - onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) + onAdd({ name: name.trim(), total_price: Number.parseFloat(String(price).replace(',', '.')) || 0, persons: Number.parseInt(persons) || null, days: Number.parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null }) setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('') setTimeout(() => nameRef.current?.focus(), 50) } @@ -33,7 +34,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) { setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} - onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }} + onPaste={e => { e.preventDefault(); setPrice(normalizePastedAmount(e.clipboardData.getData('text'))) }} placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} /> @@ -56,7 +57,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) { setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} /> - ) } diff --git a/client/src/components/Budget/BudgetPanelMemberChips.test.tsx b/client/src/components/Budget/BudgetPanelMemberChips.test.tsx new file mode 100644 index 000000000..3e7b4a04f --- /dev/null +++ b/client/src/components/Budget/BudgetPanelMemberChips.test.tsx @@ -0,0 +1,188 @@ +// FE-W4BMC-001 to FE-W4BMC-016 +import { describe, it, expect, vi } from 'vitest' +import type { BudgetItemMember } from '../../types' +import { render, screen, fireEvent } from '../../../tests/helpers/render' +import BudgetMemberChips, { ChipWithTooltip, type TripMember } from './BudgetPanelMemberChips' + +const TRIP_MEMBERS: TripMember[] = [ + { id: 1, username: 'ada', avatar_url: '/uploads/avatars/ada.png' }, + { id: 2, username: 'bob', avatar_url: null }, +] + +function member(overrides: Partial = {}): BudgetItemMember { + return { user_id: 1, username: 'ada', avatar_url: null, paid: 0, ...overrides } as unknown as BudgetItemMember +} + +function setup(props: Partial[0]> = {}) { + const onSetMembers = vi.fn() + const onTogglePaid = vi.fn() + const utils = render( + , + ) + return { onSetMembers, onTogglePaid, ...utils } +} + +/** + * The member picker trigger. A chip that can be clicked is a real button too, + * so it is the icon that tells the trigger apart from the chips before it. + */ +function picker(): HTMLElement { + const trigger = screen.getAllByRole('button').find(b => b.querySelector('.lucide-pencil, .lucide-users')) + if (!trigger) throw new Error('no picker trigger rendered') + return trigger +} + +describe('ChipWithTooltip', () => { + it('FE-W4BMC-001: falls back to the uppercased initial', () => { + const { container } = render() + + expect(container.firstElementChild).toHaveTextContent('A') + }) + + it('FE-W4BMC-002: renders the avatar when one is given', () => { + const { container } = render() + + expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png') + }) + + it('FE-W4BMC-003: hovering portals a name tooltip and leaving removes it', () => { + const { container } = render() + const chip = container.firstElementChild as HTMLElement + + fireEvent.mouseEnter(chip) + expect(screen.getByText('Ada Lovelace')).toBeInTheDocument() + + fireEvent.mouseLeave(chip) + expect(screen.queryByText('Ada Lovelace')).toBeNull() + }) + + it('FE-W4BMC-004: a paid chip turns green and the tooltip carries a Paid tag', () => { + const { container } = render() + const chip = container.firstElementChild as HTMLElement + + expect(chip.style.border).toBe('2px solid rgb(34, 197, 94)') + fireEvent.mouseEnter(chip) + expect(screen.getByText('Paid')).toBeInTheDocument() + }) + + it('FE-W4BMC-005: only a clickable chip gets the pointer cursor', () => { + const onClick = vi.fn() + const { container, unmount } = render() + const chip = container.firstElementChild as HTMLElement + + expect(chip.style.cursor).toBe('pointer') + fireEvent.click(chip) + expect(onClick).toHaveBeenCalledOnce() + unmount() + + const plain = render() + expect((plain.container.firstElementChild as HTMLElement).style.cursor).toBe('default') + }) +}) + +describe('BudgetMemberChips', () => { + it('FE-W4BMC-006: renders one chip per assigned member plus the picker button', () => { + setup({ members: [member(), member({ user_id: 2, username: 'bob' })] }) + + expect(screen.getByText('A')).toBeInTheDocument() + expect(screen.getByText('B')).toBeInTheDocument() + expect(picker()).toBeInTheDocument() + }) + + it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => { + const { container, unmount } = setup({ members: [] }) + expect(container.querySelector('.lucide-users')).not.toBeNull() + unmount() + + const withMembers = setup() + expect(withMembers.container.querySelector('.lucide-pencil')).not.toBeNull() + }) + + it('FE-W4BMC-008: clicking a chip toggles that member paid flag', () => { + const { onTogglePaid } = setup() + + fireEvent.click(screen.getByText('A')) + + expect(onTogglePaid).toHaveBeenCalledWith(1, true) + }) + + it('FE-W4BMC-009: clicking an already-paid chip clears the flag', () => { + const { onTogglePaid } = setup({ members: [member({ paid: 1 })] }) + + fireEvent.click(screen.getByText('A')) + + expect(onTogglePaid).toHaveBeenCalledWith(1, false) + }) + + it('FE-W4BMC-010: a read-only strip has no picker and no paid toggling', () => { + const { onTogglePaid } = setup({ readOnly: true }) + + expect(screen.queryByRole('button')).toBeNull() + fireEvent.click(screen.getByText('A')) + expect(onTogglePaid).not.toHaveBeenCalled() + }) + + it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => { + setup() + fireEvent.click(picker()) + + const rows = screen.getAllByRole('button').slice(2) + expect(rows).toHaveLength(2) + expect(rows[0]).toHaveTextContent('ada') + expect(rows[0].querySelector('.lucide-check')).not.toBeNull() + expect(rows[1].querySelector('.lucide-check')).toBeNull() + expect(rows[0].querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png') + expect(rows[1]).toHaveTextContent('B') + }) + + it('FE-W4BMC-012: picking an unassigned member adds them', () => { + const { onSetMembers } = setup() + fireEvent.click(picker()) + + fireEvent.click(screen.getAllByRole('button')[3]) + + expect(onSetMembers).toHaveBeenCalledWith([1, 2]) + }) + + it('FE-W4BMC-013: picking an assigned member removes them', () => { + const { onSetMembers } = setup() + fireEvent.click(picker()) + + fireEvent.click(screen.getAllByRole('button')[2]) + + expect(onSetMembers).toHaveBeenCalledWith([]) + }) + + it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => { + setup() + const trigger = picker() + fireEvent.click(trigger) + + fireEvent.mouseDown(screen.getAllByRole('button')[2]) + expect(screen.getAllByRole('button')).toHaveLength(4) + + fireEvent.mouseDown(trigger) + expect(screen.getAllByRole('button')).toHaveLength(4) + + fireEvent.mouseDown(document.body) + expect(screen.getAllByRole('button')).toHaveLength(2) + }) + + it('FE-W4BMC-015: the trigger toggles the picker closed again', () => { + setup() + const trigger = picker() + + fireEvent.click(trigger) + expect(screen.getAllByRole('button')).toHaveLength(4) + + fireEvent.click(trigger) + expect(screen.getAllByRole('button')).toHaveLength(2) + }) + + it('FE-W4BMC-016: the non-compact variant renders larger chips', () => { + setup({ compact: false }) + + expect(screen.getByText('A')).toHaveStyle({ width: '30px' }) + expect(picker().style.width).toBe('28px') + }) +}) diff --git a/client/src/components/Budget/BudgetPanelMemberChips.tsx b/client/src/components/Budget/BudgetPanelMemberChips.tsx index c042638c1..b8bb8a244 100644 --- a/client/src/components/Budget/BudgetPanelMemberChips.tsx +++ b/client/src/components/Budget/BudgetPanelMemberChips.tsx @@ -1,4 +1,4 @@ -import ReactDOM from 'react-dom' +import { createPortal } from 'react-dom' import { useState, useEffect, useRef, useCallback } from 'react' import { Pencil, Users, Check } from 'lucide-react' import type { BudgetItemMember } from '../../types' @@ -22,7 +22,7 @@ interface ChipWithTooltipProps { export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) - const ref = useRef(null) + const ref = useRef(null) const onEnter = () => { if (ref.current) { @@ -35,23 +35,35 @@ export function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: const borderColor = paid ? '#22c55e' : 'var(--border-primary)' const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + // Identical for both shells — a clickable chip is a real button so it can be + // tabbed to and pressed, a decorative one stays a plain div. + const chipStyle = { + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', + padding: 0, fontFamily: 'inherit', + } + const inner = avatarUrl + ? + : label?.[0]?.toUpperCase() + return ( <> -
setHover(false)} - onClick={onClick} - style={{ - width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, - background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', - overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', - transition: 'border-color 0.15s, background 0.15s', - }}> - {avatarUrl - ? - : label?.[0]?.toUpperCase() - } -
- {hover && ReactDOM.createPortal( + {onClick ? ( + + ) : ( +
{ ref.current = el }} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)} + style={chipStyle}> + {inner} +
+ )} + {hover && createPortal(
))} {!readOnly && ( - )} - {showDropdown && ReactDOM.createPortal( + {showDropdown && createPortal(
{ const isActive = memberIds.includes(tm.id) return ( -
diff --git a/client/src/components/Budget/BudgetPanelPieChart.test.tsx b/client/src/components/Budget/BudgetPanelPieChart.test.tsx new file mode 100644 index 000000000..884287378 --- /dev/null +++ b/client/src/components/Budget/BudgetPanelPieChart.test.tsx @@ -0,0 +1,54 @@ +// FE-W4PIE-001 to FE-W4PIE-006 +import { describe, it, expect } from 'vitest' +import { render, screen } from '../../../tests/helpers/render' +import PieChart from './BudgetPanelPieChart' + +const SEGMENTS = [ + { label: 'Food', value: 300, color: '#ef4444' }, + { label: 'Hotels', value: 100, color: '#3b82f6' }, +] + +describe('BudgetPanelPieChart', () => { + it('FE-W4PIE-001: renders nothing without segments', () => { + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + it('FE-W4PIE-002: renders nothing when every segment is zero', () => { + const { container } = render( + , + ) + expect(container).toBeEmptyDOMElement() + }) + + it('FE-W4PIE-003: turns the segment shares into consecutive conic-gradient stops', () => { + const { container } = render() + const pie = container.querySelector('.trek-pie-reveal') as HTMLElement + + expect(pie.style.background).toBe('conic-gradient(rgb(239, 68, 68) 0deg 270deg, rgb(59, 130, 246) 270deg 360deg)') + }) + + it('FE-W4PIE-004: shows the total label in the donut hole', () => { + render() + + expect(screen.getByText('400 €')).toBeInTheDocument() + }) + + it('FE-W4PIE-005: defaults to a 200px pie with a 55% hole', () => { + const { container } = render() + const root = container.firstElementChild as HTMLElement + const hole = screen.getByText('400 €').parentElement as HTMLElement + + expect(root.style.width).toBe('200px') + expect(Math.round(parseFloat(hole.style.width))).toBe(110) + }) + + it('FE-W4PIE-006: scales pie and hole from the size prop', () => { + const { container } = render() + const root = container.firstElementChild as HTMLElement + const hole = screen.getByText('400 €').parentElement as HTMLElement + + expect(root.style.height).toBe('120px') + expect(Math.round(parseFloat(hole.style.height))).toBe(66) + }) +}) diff --git a/client/src/components/Budget/BudgetPanelRingAvatar.tsx b/client/src/components/Budget/BudgetPanelRingAvatar.tsx index 585bfece8..3c116d049 100644 --- a/client/src/components/Budget/BudgetPanelRingAvatar.tsx +++ b/client/src/components/Budget/BudgetPanelRingAvatar.tsx @@ -15,7 +15,7 @@ export default function RingAvatar({ userId, username, avatarUrl, size = 34, inn overflow: 'hidden', fontSize: size < 28 ? 10 : 12, fontWeight: 600, color: textColor, }}> - {avatarUrl ? : username?.[0]?.toUpperCase()} + {avatarUrl ? {username : username?.[0]?.toUpperCase()}
) diff --git a/client/src/components/Budget/BudgetPanelSummary.tsx b/client/src/components/Budget/BudgetPanelSummary.tsx index 70abfae18..0b9d77866 100644 --- a/client/src/components/Budget/BudgetPanelSummary.tsx +++ b/client/src/components/Budget/BudgetPanelSummary.tsx @@ -75,7 +75,7 @@ export default function BudgetSummary({ theme, currency, locale, grandTotal, has {/* Settlement dropdown inside the total card */} {hasMultipleMembers && settlement && settlement.flows.length > 0 && (
- - ))}
- )} - + {SettleFlows()} {/* balances */}
{t('costs.balances')}
- + {BalancesList({ balances: settlement?.balances || [] })}
{/* by category */}
{t('costs.byCategory')}
- + {CategoryBreakdown()}
@@ -563,6 +518,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps .costs-root .text-content-muted { color: var(--c-ink2) !important; } .costs-root .text-content-faint { color: var(--c-ink3) !important; } .costs-root .exp-actions { opacity: 1; } + .costs-root .exp-actions button { background: none; } + .costs-root .exp-actions button:hover { background: var(--bg-secondary); color: var(--c-ink); } + /* Destructive actions keep their own tint: the neutral one would read as + "safe" on the button that deletes the expense. */ + .costs-root .exp-actions .exp-action-danger:hover { background: rgba(220,38,38,0.14); color: #dc2626; } @media (max-width: 1100px) { .costs-root .costs-summary { grid-template-columns: 1fr 1fr !important; } .costs-root .costs-grid { grid-template-columns: 1fr !important; } @@ -574,8 +534,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ) + // Settle-up and Balances both come from the one settlement request, so they + // share the notice that says the numbers are missing rather than zero. + function loadFailed() { + return
{t('common.unknownError')}
+ } + // ── shared settle-flow list ────────────────────────────────────────────── function SettleFlows() { + if (settlementError) return loadFailed() const flows = settlement?.flows || [] if (flows.length === 0) return (
@@ -593,7 +560,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{fmt(f.amount)} - {canEdit && } + {canEdit && }
))} @@ -602,6 +569,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps } // ── mobile layout (Budget1Mobile.html): single flat column, total card on top ── + // + // Called as a plain function — `{MobileBody()}`, not `` — and the + // same goes for ExpenseRow, SettleFlows, BalancesList and CategoryBreakdown. + // A component declared inside this one is a fresh component type on every + // render, so React tears the old subtree down and mounts a new one instead of + // updating it; the search box then loses focus after every keystroke. Calling + // them keeps the markup inline where it always was. Do not "tidy" these back + // into elements. function MobileBody() { return (
@@ -614,7 +589,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {t('costs.youPaid')} · {fmt0(totals.myPaid)}
{canEdit && ( - )} @@ -653,17 +628,17 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{t('costs.settleUp')} {(settlement?.flows || []).length}
{canEdit && ( - + )}
- + {SettleFlows()} {/* Expenses */}
{t('costs.expenses')}
-
{(['all', 'mine', 'owed'] as const).map(f => ( - + ))}
@@ -691,8 +666,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
{!dayFilter &&
{g.day}{t('costs.spent', { amount: fmt(dtot) })}
}
{g.entries.map(en => en.kind === 'expense' - ? - : )}
+ ? {ExpenseRow({ e: en.e })} + : {SettlementRow({ s: en.s })})}
) })} @@ -701,19 +676,43 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {/* Balances */}
{t('costs.balances')}
- + {BalancesList({ balances: settlement?.balances || [] })}
{/* By category */}
{t('costs.byCategory')}
- + {CategoryBreakdown()}
) } // ── inline subcomponents (close over helpers) ──────────────────────────── + /** + * Edit and delete, in a capsule beside the row rather than inside it — the + * mobile card has always done it this way, and out here the buttons stop + * competing with the amount for the right edge of the card. + */ + function RowActions({ onEdit, onDelete, deleteLabel }: { onEdit: () => void; onDelete: () => void; deleteLabel: string }) { + // No `background` here on purpose: an inline value would beat the :hover + // rule in the stylesheet block below, which is how the first attempt at + // this silently did nothing. + const btn: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + width: 30, height: 30, borderRadius: 999, border: 0, + cursor: 'pointer', padding: 0, + } + return ( +
+ + +
+ ) + } + + // Called, not rendered — see MobileBody: an element here would remount the + // list on every keystroke in the search box. function ExpenseRow({ e }: { e: BudgetItem }) { const c = catMeta(e.category) const Icon = c.Icon @@ -721,17 +720,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps const payers = (e.payers || []).filter(p => p.amount > 0) const net = round2(myPaidOf(e) - myShareOf(e)) const unfinished = isUnfinished(e) + const note = readUserNote(e) + const open = expandedNoteId === e.id + + // Fixed columns, identical on every row, so the eye can run down the list + // instead of re-finding each field on each line. A row without a note leaves + // its column empty rather than letting everything after it drift left, which + // is what made the list read as unstructured. + const cols = isMobile ? '46px 1fr auto' : '46px minmax(200px, 1fr) minmax(0, 1.5fr) auto' + return ( -
+
+
+ {/* The category rides the card edge as a tab, the way the mobile card + does, so it stops taking up a slot inside the row itself. */} + {!isMobile && ( + + )} + {isMobile && unfinished && ( ! )} + + {/* What it was. */}
-
- {e.name} +
+ {e.name} {unfinished && !isMobile && ( ! @@ -739,8 +759,15 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps )}
+ {cur !== base && ( +
+ {fmt(e.total_price, cur)} {'→'} {fmt(baseTotal(e))} +
+ )} + {/* Under the name, because a chip reading "Y 122,00" says nothing on + its own — the name above it is what gives it a meaning. */} {payers.length > 0 && ( -
+
{payers.map(p => ( @@ -749,29 +776,38 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps ))}
)} - {!isMobile && ( -
- {t(c.labelKey)}{cur !== base ? ` · ${fmt(e.total_price, cur)} → ${fmt(baseTotal(e))}` : ''} -
- )}
+ + {/* The note, marked by a rule in the category colour instead of a box, so + it reads as part of the row rather than something dropped onto it. */} + {!isMobile && ( + note ? ( + + ) : + )} + + {/* The money, and what to do with it. */}
-
-
{fmt(baseTotal(e))}
- {!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( -
0 ? '#16a34a' : '#dc2626' }}> +
+ {fmt(baseTotal(e))} + {!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && ( + 0 ? 'rgba(22,163,74,0.13)' : 'rgba(220,38,38,0.13)'), fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, color: net > 0 ? '#16a34a' : '#dc2626' }}> {net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })} -
+ )}
- {canEdit && ( -
- - -
- )}
+ {canEdit && RowActions({ onEdit: () => { setEditing(e); setModalOpen(true) }, onDelete: () => handleDelete(e.id), deleteLabel: t('common.delete') })} +
) } @@ -781,7 +817,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps // Legacy transfers carry no currency and were entered in the display base. const cur = (s.currency || base).toUpperCase() return ( -
+
+
@@ -793,20 +830,25 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {personName(s.from_user_id)} → {personName(s.to_user_id)}
+ {/* A payment carries no note, but it keeps the column so its amount lands + on the same axis as every expense above it. */} + {!isMobile && }
-
{fmt(convert(s.amount, cur))}
- {canEdit && ( -
- - -
- )} + {fmt(convert(s.amount, cur))}
+ {canEdit && ( +
+ + +
+ )} +
) } function BalancesList({ balances }: { balances: SettlementData['balances'] }) { + if (settlementError) return loadFailed() const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 }) const max = Math.max(1, ...rows.map(r => Math.abs(r.balance))) return ( @@ -928,7 +970,7 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav const [cur, setCur] = useState((editing?.currency || currency).toUpperCase()) const [saving, setSaving] = useState(false) - const amt = parseFloat(amount) || 0 + const amt = Number.parseFloat(amount) || 0 const valid = amt > 0 && fromId !== toId const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username })) @@ -949,8 +991,8 @@ function SettlementModal({ tripId, people, me, editing, currency, onClose, onSav - - + +
}>
@@ -990,6 +1032,8 @@ export interface ExpensePrefill { category?: string amount?: number reservationId?: number + /** Set when the expense is being created from a place (#1298). */ + placeId?: number } export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: { @@ -997,6 +1041,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo }) { const { t, locale } = useTranslation() const toast = useToast() + const isMobile = useIsMobile() const { addBudgetItem, updateBudgetItem } = useTripStore() const { convert } = useExchangeRates(base) const sym = (c: string) => SYMBOLS[c] || (c + ' ') @@ -1004,9 +1049,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [name, setName] = useState(editing?.name || prefill?.name || '') const [cat, setCat] = useState(editing ? catMeta(editing.category).key : (prefill?.category || 'food')) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) - const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) + const [day, setDay] = useState(editing?.expense_date || localToday()) + const [note, setNote] = useState(() => readUserNote(editing)) const [total, setTotal] = useState(() => { - if (editing) return editing.total_price ? String(editing.total_price) : '' + if (editing) return editing.total_price ? String(cleanAmount(editing.total_price)) : '' if (prefill?.amount != null) return String(prefill.amount) return '' }) @@ -1038,7 +1084,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const [pinnedPayers, setPinnedPayers] = useState>(() => new Set(initialPayers.map(p => p.user_id))) const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => { - if (editing?.note && editing.note.startsWith('TICKETJSON:')) { + if (hasTicketSplit(editing)) { return 'ticket' } if (editing && editing.members && editing.members.length > 0) { @@ -1048,22 +1094,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return 'equally' }) - const [ticketItems, setTicketItems] = useState(() => { - if (editing?.note && editing.note.startsWith('TICKETJSON:')) { - try { - const parsed = JSON.parse(editing.note.slice(11)) - return (parsed.items || []).map((item: any) => ({ - id: String(Math.random()), - name: item.name, - price: String(item.price), - participants: new Set(item.parts || []) - })) - } catch { - return [] - } - } - return [] - }) + const [ticketItems, setTicketItems] = useState(() => readTicketItems(editing)) const [customAmounts, setCustomAmounts] = useState>(() => { const m: Record = {} @@ -1085,8 +1116,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo return calculateTicketShares(ticketItems) }, [ticketItems]) - const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0) - const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0) + const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0) + const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100) const each = participants.size > 0 ? totalNum / participants.size : 0 const equalShares = useMemo(() => { @@ -1099,13 +1130,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const enteredSum = [...participants] .filter(id => customAmounts[id]) - .reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0) + .reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0) const remaining = Math.max(0, totalNum - enteredSum) return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0) }, [totalNum, participants, customAmounts, editing]) - const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0) + const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (Number.parseFloat(item.price) || 0) > 0 && item.participants.size > 0) const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum)) const valid = name.trim().length > 0 && payersOk && ( isTicketMode @@ -1227,15 +1258,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo const save = async () => { if (!valid) return setSaving(true) + // A picked payer always goes out, even when nobody shares the expense: the + // server re-derives total_price from the payer sum (CostsPanel.helpers), so + // dropping the payer would store the entry with a total of 0. const payerList = multiPayer ? [...payerIds] - .map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 })) + .map(id => ({ user_id: id, amount: Number.parseFloat(payerAmounts[id]) || 0 })) .filter(p => p.amount > 0) - : (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : [] - const memberList = [...participants].map(id => ({ + : payerId > 0 ? [{ user_id: payerId, amount: totalNum }] : [] + // A receipt line can name somebody who is not ticked as a participant. Sending + // only the ticked set would drop their share, leaving the member sum short of + // total_price and handing the settlement a difference it can never clear (#1382). + const memberIds = splitMode === 'ticket' + ? [...new Set([...participants, ...Object.keys(ticketInfo.shares).map(Number)])].sort((a, b) => a - b) + : [...participants] + const memberList = memberIds.map(id => ({ user_id: id, amount: splitMode === 'custom' - ? (parseFloat(customAmounts[id]) || 0) + ? (Number.parseFloat(customAmounts[id]) || 0) : splitMode === 'ticket' ? (ticketInfo.shares[id] || 0) : null @@ -1246,17 +1286,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo currency, payers: payerList, members: memberList, - member_ids: [...participants], + member_ids: memberIds, expense_date: day || null, total_price: totalNum, - note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({ - items: ticketItems.map(item => ({ - name: item.name, - price: item.price, - parts: [...item.participants] - })) - }) : null, + note: note.trim() || null, + ticket_json: splitMode === 'ticket' ? writeTicketItems(ticketItems) : null, ...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}), + ...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}), } try { if (editing) await updateBudgetItem(tripId, editing.id, data) @@ -1270,17 +1306,25 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo } const inputCls = 'w-full bg-surface-input border border-edge text-content' - const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]' + const labelCls = 'block text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2' + // Borrowed from the trip dialog (TripFormModal): related fields sit together in + // a panel instead of running as one undifferentiated column of inputs. + const panelCls = 'rounded-2xl border border-edge bg-surface-secondary p-4' return ( - - - + +
}> -
+ {/* Two columns on desktop: what the expense was on the left, how it is + shared on the right. The split follows the two questions the dialog + actually asks, so neither side is a leftovers pile. */} +
+
+
setName(e.target.value)} placeholder={t('costs.namePlaceholder')} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 'calc(14px * var(--fs-scale-body, 1))', outline: 'none' }} /> @@ -1290,7 +1334,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{sym(currency)} - @@ -1318,13 +1362,15 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
)} -
+
+ +
{COST_CATEGORY_LIST.map(c => { const Icon = c.Icon; const on = cat === c.key return ( -
-
-
+
+ +
+
+
@@ -1428,7 +1477,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
handleUpdateItemName(item.id, e.target.value)} className="bg-surface-input border border-edge text-content" @@ -1438,8 +1487,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {sym(currency)} handleUpdateItemPrice(item.id, v)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }} @@ -1451,7 +1500,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
- Splitting: + {t('costs.ticketSplitting')} {people.map((p, pIdx) => { const active = item.participants.has(p.id) return ( @@ -1475,12 +1524,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{ticketItems.length > 0 && (
-
Individual Shares Summary
+
{t('costs.ticketShares')}
{people.map(p => { const share = ticketInfo.shares[p.id] || 0 @@ -1515,13 +1564,13 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo {sym(currency)}{(equalShares[p.id] || 0).toFixed(2)} ) : ( - Excluded + {t('costs.excluded')} ) ) : ( on ? (
{sym(currency)} - handleCustomAmountChange(p.id, e.target.value)} className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
@@ -1540,15 +1589,27 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo ) : ( - {customBalanced - ? 'Split matches total' - : `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`} + {customBalanced + ? t('costs.splitBalanced') + : t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', { + sum: sym(currency) + splitSum.toFixed(2), + total: sym(currency) + totalNum.toFixed(2), + diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2), + })} )}
)}
+ +
+ +