mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9626fce9 | |||
| 3398da633b | |||
| 31f99f0e4e | |||
| 56655d53b4 | |||
| f91721c73e | |||
| 0a58e3270b | |||
| e224befde7 | |||
| f46cc8a98e | |||
| 1378c95078 | |||
| bb477645a3 | |||
| e65acb3de7 | |||
| 3c040fab11 | |||
| 49b3af8b0d | |||
| 093e069ccc | |||
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 |
+2
-3
@@ -15,8 +15,6 @@ uploads
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
docker-compose.minio-test.yml
|
||||
minio-licence.key
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
@@ -32,7 +30,8 @@ Thumbs.db
|
||||
sonar-project.properties
|
||||
server/tests/
|
||||
server/vitest.config.ts
|
||||
server/reset-admin.js
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
wiki/
|
||||
scripts/
|
||||
charts/
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
*.sh text eol=lf
|
||||
# Binary files — no line ending conversion
|
||||
*.png binary
|
||||
*.webp binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
@@ -26,5 +25,3 @@
|
||||
*.eot binary
|
||||
*.pdf binary
|
||||
*.zip binary
|
||||
# A GPX fixture whose byte-order mark and CRLF line endings are what it tests
|
||||
server/tests/fixtures/gpx/bom-crlf.gpx -text
|
||||
|
||||
@@ -8,11 +8,11 @@ body:
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/liketrek/TREK/wiki
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/liketrek/TREK/discussions
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -13,8 +13,8 @@
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
|
||||
@@ -10,7 +10,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
@@ -77,7 +76,7 @@ jobs:
|
||||
body: [
|
||||
'## Wrong place for feature requests',
|
||||
'',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'',
|
||||
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||
].join('\n'),
|
||||
|
||||
@@ -18,7 +18,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
@@ -132,8 +131,7 @@ jobs:
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -145,24 +143,9 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest-pre" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- 'wiki/**'
|
||||
- '.github/workflows/**'
|
||||
- '.github/ISSUE_TEMPLATE/**'
|
||||
- '.github/FUNDING.yml'
|
||||
- '.github/PULL_REQUEST_TEMPLATE.md'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
@@ -22,22 +32,15 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.RELEASE_APP_ID }}
|
||||
private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
@@ -109,7 +112,7 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git tag "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
@@ -176,8 +179,7 @@ jobs:
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
# Deliberately NOT merged: the per-platform folder names are what lets
|
||||
# the manifest below be assembled in a fixed order.
|
||||
merge-multiple: true
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -189,24 +191,9 @@ jobs:
|
||||
- name: Create and push multi-arch manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ needs.version-bump.outputs.version }}"
|
||||
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||
MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)"
|
||||
# amd64 first, always. This used to be a glob over hash-named files, so
|
||||
# the order changed from one release to the next: 3.4.1 listed amd64
|
||||
# first and 4.0.0 listed arm64 first. Clients that read the first entry
|
||||
# of the index rather than matching their own platform then compare the
|
||||
# wrong digest and report "no update" — which is what Unraid users hit
|
||||
# on :latest after 4.0.0 shipped. The image was fine; the order was not.
|
||||
digests=()
|
||||
for platform in linux-amd64 linux-arm64; do
|
||||
dir="digests-$platform"
|
||||
[ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; }
|
||||
for f in "$dir"/*; do
|
||||
[ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; }
|
||||
digests+=("mauriceboe/trek@sha256:$(basename "$f")")
|
||||
done
|
||||
done
|
||||
docker buildx imagetools create \
|
||||
-t "mauriceboe/trek:latest" \
|
||||
-t "mauriceboe/trek:$MAJOR_TAG" \
|
||||
@@ -230,4 +217,3 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
charts_url: https://chart.liketrek.com
|
||||
|
||||
@@ -6,7 +6,6 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -68,38 +67,6 @@ 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 {
|
||||
|
||||
@@ -2,11 +2,6 @@ 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
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
name: Publish plugin-sdk to npm
|
||||
|
||||
# Publishes trek-plugin-sdk when a tag like `plugin-sdk-v1.2.0` is pushed.
|
||||
# One-time setup: add an npm automation token as the repo secret NPM_TOKEN.
|
||||
# The package's prepublishOnly hook builds + tests before publishing.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'plugin-sdk-v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: plugin-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
# 22 matches the TREK server runtime and has node:sqlite, which the
|
||||
# dev-server tests exercise.
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- run: npm ci
|
||||
- run: npm publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -9,15 +9,8 @@ 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
|
||||
# Scout login can never succeed there.
|
||||
if: github.repository == 'liketrek/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -41,5 +34,4 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
+52
-412
@@ -1,127 +1,18 @@
|
||||
name: Tests & SonarQube
|
||||
name: Tests
|
||||
|
||||
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'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# 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'
|
||||
# The image's start-up shape is pinned by a unit test
|
||||
# (server/tests/unit/docker-entrypoint.test.ts), so a change to the
|
||||
# Dockerfile or to the entrypoint script has to run the suite — that is
|
||||
# exactly the change the test exists to catch.
|
||||
- 'Dockerfile'
|
||||
- 'server/scripts/**'
|
||||
# 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
|
||||
@@ -136,9 +27,32 @@ jobs:
|
||||
- name: Check i18n key parity
|
||||
run: node shared/scripts/i18n-parity.mjs --strict
|
||||
|
||||
server-quality:
|
||||
name: Server Types & Lint
|
||||
shared-contracts:
|
||||
name: Shared Contracts (Zod)
|
||||
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
|
||||
|
||||
@@ -151,40 +65,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
|
||||
|
||||
client-quality:
|
||||
name: Client Types & Lint
|
||||
- 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
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -211,287 +125,13 @@ jobs:
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
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
|
||||
- name: Run tests
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
- name: Upload coverage
|
||||
if: success()
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: sonar-client-coverage
|
||||
name: frontend-coverage
|
||||
path: client/coverage/
|
||||
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
|
||||
retention-days: 7
|
||||
|
||||
@@ -17,7 +17,6 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+1
-5
@@ -51,7 +51,6 @@ yarn-error.log*
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
@@ -65,8 +64,5 @@ 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
|
||||
.full-review
|
||||
@@ -1,128 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
|
||||
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. 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, sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
|
||||
|
||||
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not `npm -w <pkg>`**.
|
||||
|
||||
```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 (server and shared run eslint --fix and rewrite files; client is check-only)
|
||||
npm run format # prettier --write all three
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright (local only)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
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 (CI gate — every non-`en` locale must have the identical file set and 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: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server invariants
|
||||
|
||||
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
|
||||
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
|
||||
|
||||
## Client invariants
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and 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, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **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 (on the client: 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.** 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 (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
|
||||
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
|
||||
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
| Condition (new code) | Must be |
|
||||
|---|---|
|
||||
| Coverage | ≥ 80% |
|
||||
| Duplicated lines | ≤ 3% |
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
|
||||
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
|
||||
@@ -1,136 +0,0 @@
|
||||
# Contributor Covenant 3.0 Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We pledge to make our community welcoming, safe, and equitable for all.
|
||||
|
||||
We are committed to fostering an environment that respects and promotes the dignity, rights, and contributions of all
|
||||
individuals, regardless of characteristics including race, ethnicity, caste, color, age, physical characteristics,
|
||||
neurodiversity, disability, sex or gender, gender identity or expression, sexual orientation, language, philosophy or
|
||||
religion, national or social origin, socio-economic position, level of education, or other status. The same privileges
|
||||
of participation are extended to everyone who participates in good faith and in accordance with this Covenant.
|
||||
|
||||
## Encouraged Behaviors
|
||||
|
||||
While acknowledging differences in social norms, we all strive to meet our community's expectations for positive
|
||||
behavior. We also understand that our words and actions may be interpreted differently than we intend based on culture,
|
||||
background, or native language.
|
||||
|
||||
With these considerations in mind, we agree to behave mindfully toward each other and act in ways that center our shared
|
||||
values, including:
|
||||
|
||||
1. Respecting the **purpose of our community**, our activities, and our ways of gathering.
|
||||
2. Engaging **kindly and honestly** with others.
|
||||
3. Respecting **different viewpoints** and experiences.
|
||||
4. **Taking responsibility** for our actions and contributions.
|
||||
5. Gracefully giving and accepting **constructive feedback**.
|
||||
6. Committing to **repairing harm** when it occurs.
|
||||
7. Behaving in other ways that promote and sustain the **well-being of our community**.
|
||||
|
||||
## Restricted Behaviors
|
||||
|
||||
We agree to restrict the following behaviors in our community. Instances, threats, and promotion of these behaviors are
|
||||
violations of this Code of Conduct.
|
||||
|
||||
1. **Harassment.** Violating explicitly expressed boundaries or engaging in unnecessary personal attention after any
|
||||
clear request to stop.
|
||||
2. **Character attacks.** Making insulting, demeaning, or pejorative comments directed at a community member or group of
|
||||
people.
|
||||
3. **Stereotyping or discrimination.** Characterizing anyone’s personality or behavior on the basis of immutable
|
||||
identities or traits.
|
||||
4. **Sexualization.** Behaving in a way that would generally be considered inappropriately intimate in the context or
|
||||
purpose of the community.
|
||||
5. **Violating confidentiality**. Sharing or acting on someone's personal or private information without their
|
||||
permission.
|
||||
6. **Endangerment.** Causing, encouraging, or threatening violence or other harm toward any person or group.
|
||||
7. Behaving in other ways that **threaten the well-being** of our community.
|
||||
|
||||
### Other Restrictions
|
||||
|
||||
1. **Misleading identity.** Impersonating someone else for any reason, or pretending to be someone else to evade
|
||||
enforcement actions.
|
||||
2. **Failing to credit sources.** Not properly crediting the sources of content you contribute.
|
||||
3. **Promotional materials**. Sharing marketing or other commercial content in a way that is outside the norms of the
|
||||
community.
|
||||
4. **Irresponsible communication.** Failing to responsibly present content which includes, links or describes any other
|
||||
restricted behaviors.
|
||||
|
||||
## Reporting an Issue
|
||||
|
||||
Tensions can occur between community members even when they are trying their best to collaborate. Not every conflict
|
||||
represents a code of conduct violation, and this Code of Conduct reinforces encouraged behaviors and norms that can help
|
||||
avoid conflicts and minimize harm.
|
||||
|
||||
When an incident does occur, it is important to report it promptly. To report a possible violation, **send an email to
|
||||
report@liketrek.com**.
|
||||
|
||||
Community Moderators take reports of violations seriously and will make every effort to respond in a timely manner. They
|
||||
will investigate all reports of code of conduct violations, reviewing messages, logs, and recordings, or interviewing
|
||||
witnesses and other participants. Community Moderators will keep investigation and enforcement actions as transparent as
|
||||
possible while prioritizing safety and confidentiality. In order to honor these values, enforcement actions are carried
|
||||
out in private with the involved parties, but communicating to the whole community may be part of a mutually agreed upon
|
||||
resolution.
|
||||
|
||||
## Addressing and Repairing Harm
|
||||
|
||||
****
|
||||
|
||||
If an investigation by the Community Moderators finds that this Code of Conduct has been violated, the following
|
||||
enforcement ladder may be used to determine how best to repair harm, based on the incident's impact on the individuals
|
||||
involved and the community as a whole. Depending on the severity of a violation, lower rungs on the ladder may be
|
||||
skipped.
|
||||
|
||||
1) Warning
|
||||
1) Event: A violation involving a single incident or series of incidents.
|
||||
2) Consequence: A private, written warning from the Community Moderators.
|
||||
3) Repair: Examples of repair include a private written apology, acknowledgement of responsibility, and seeking
|
||||
clarification on expectations.
|
||||
2) Temporarily Limited Activities
|
||||
1) Event: A repeated incidence of a violation that previously resulted in a warning, or the first incidence of a
|
||||
more serious violation.
|
||||
2) Consequence: A private, written warning with a time-limited cooldown period designed to underscore the
|
||||
seriousness of the situation and give the community members involved time to process the incident. The cooldown
|
||||
period may be limited to particular communication channels or interactions with particular community members.
|
||||
3) Repair: Examples of repair may include making an apology, using the cooldown period to reflect on actions and
|
||||
impact, and being thoughtful about re-entering community spaces after the period is over.
|
||||
3) Temporary Suspension
|
||||
1) Event: A pattern of repeated violation which the Community Moderators have tried to address with warnings, or a
|
||||
single serious violation.
|
||||
2) Consequence: A private written warning with conditions for return from suspension. In general, temporary
|
||||
suspensions give the person being suspended time to reflect upon their behavior and possible corrective actions.
|
||||
3) Repair: Examples of repair include respecting the spirit of the suspension, meeting the specified conditions for
|
||||
return, and being thoughtful about how to reintegrate with the community when the suspension is lifted.
|
||||
4) Permanent Ban
|
||||
1) Event: A pattern of repeated code of conduct violations that other steps on the ladder have failed to resolve, or
|
||||
a violation so serious that the Community Moderators determine there is no way to keep the community safe with
|
||||
this person as a member.
|
||||
2) Consequence: Access to all community spaces, tools, and communication channels is removed. In general, permanent
|
||||
bans should be rarely used, should have strong reasoning behind them, and should only be resorted to if working
|
||||
through other remedies has failed to change the behavior.
|
||||
3) Repair: There is no possible repair in cases of this severity.
|
||||
|
||||
This enforcement ladder is intended as a guideline. It does not limit the ability of Community Managers to use their
|
||||
discretion and judgment, in keeping with the best interests of our community.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing
|
||||
the community in public or other spaces. Examples of representing our community include using an official email address,
|
||||
posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 3.0, permanently available
|
||||
at [https://www.contributor-covenant.org/version/3/0/](https://www.contributor-covenant.org/version/3/0/).
|
||||
|
||||
Contributor Covenant is stewarded by the Organization for Ethical Source and licensed under CC BY-SA 4.0. To view a copy
|
||||
of this license,
|
||||
visit [https://creativecommons.org/licenses/by-sa/4.0/](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||
|
||||
For answers to common questions about Contributor Covenant, see the FAQ
|
||||
at [https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq). Translations are provided
|
||||
at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations). Additional
|
||||
enforcement and community guideline resources can be found
|
||||
at [https://www.contributor-covenant.org/resources](https://www.contributor-covenant.org/resources). The enforcement
|
||||
ladder was inspired by the work of [Mozilla’s code of conduct team](https://github.com/mozilla/inclusion).
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
|
||||
|
||||
## Development Environment
|
||||
|
||||
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
|
||||
+30
-72
@@ -1,10 +1,3 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -20,15 +13,13 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
RUN npm run build --workspace=client
|
||||
|
||||
# ── Stage 3: server ──────────────────────────────────────────────────────────
|
||||
# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one
|
||||
# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x.
|
||||
# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage.
|
||||
FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
@@ -48,33 +39,26 @@ 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.
|
||||
#
|
||||
# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for
|
||||
# amd64 and arm64 inside its tarball, so there is nothing left to build: the old
|
||||
# python3 + build-essential fallback compiled it against whatever Node headers
|
||||
# the base image had that day, and 24.19+ headers give an addon that aborts with
|
||||
# "Assertion failed: (env) != nullptr" the first time a statement is collected
|
||||
# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises
|
||||
# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's
|
||||
# gypfile:false (npm/cli#9837). The only other install scripts among the runtime
|
||||
# deps are esbuild's binary check, redundant next to its optional platform
|
||||
# package, and a telemetry ping.
|
||||
# better-sqlite3 native addon requires build tools (purged after compile).
|
||||
# kitinerary-extractor for booking-confirmation import:
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \
|
||||
libkitinerary-bin libsqlite3-0 && \
|
||||
npm ci --workspace=server --omit=dev --ignore-scripts && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
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) — the entrypoint script uses
|
||||
# it to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
wget -qO /tmp/ki.tgz https://cdn.kde.org/ci-builds/pim/kitinerary/release-26.04/linux/kitinerary-extractor-x86_64-26.04.2.tgz && \
|
||||
echo "ba5cfb4a2353157c8f54cbeaea0097c5bf2c3a810e0342f63d6e524826176628 /tmp/ki.tgz" | sha256sum -c && \
|
||||
tar -xz -C /usr/local -f /tmp/ki.tgz bin/kitinerary-extractor share/locale && \
|
||||
rm /tmp/ki.tgz; \
|
||||
else \
|
||||
apt-get install -y --no-install-recommends libkitinerary-bin && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
fi && \
|
||||
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
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
@@ -83,42 +67,24 @@ 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 --chown=node:node --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --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 --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 --chown=node:node wiki ./wiki
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
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 --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 --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
|
||||
COPY server/tsconfig.json ./server/
|
||||
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
|
||||
|
||||
# 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 && \
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
ln -s /app/uploads /app/server/uploads && \
|
||||
ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
chown -h node:node /app/server/uploads /app/server/data
|
||||
chown -R node:node /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_USE_ENV_PROXY=1
|
||||
ENV PORT=3000
|
||||
ARG APP_VERSION=dev
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
@@ -128,14 +94,6 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
# Start-up lives in a script, not an inline `sh -c` string: container management
|
||||
# UIs re-tokenise Config.Cmd when you edit a container in place, and a command
|
||||
# carrying quotes does not survive that round-trip (the container then dies with
|
||||
# a shell syntax error before Node starts). A CMD of one bare token does.
|
||||
COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint
|
||||
# Root-owned on purpose — this is what calls gosu, so it runs before the drop to
|
||||
# node. chmod is explicit because a checkout on Windows carries no exec bit.
|
||||
RUN chmod 0755 /usr/local/bin/trek-entrypoint
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["/usr/local/bin/trek-entrypoint"]
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -1,3 +1,577 @@
|
||||
# MCP Integration
|
||||
|
||||
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
|
||||
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 <token>` 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)
|
||||
|
||||

|
||||
|
||||
@@ -21,34 +21,12 @@ 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, place details & descriptions
|
||||
## OpenStreetMap — geocoding
|
||||
|
||||
Atlas reverse-geocodes places via the **Nominatim** service, and place search,
|
||||
details and descriptions come from Nominatim and **Overpass**. Data is
|
||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding 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**
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<div align="center">
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.svg" />
|
||||
<img src="docs/logo-trek-dark.svg" alt="TREK" height="96" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/logo-trek-light.gif" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/logo-trek-dark.gif" />
|
||||
<img src="docs/logo-trek-dark.gif" alt="TREK" height="96" />
|
||||
</picture>
|
||||
|
||||
<br />
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/subtitle-light.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="docs/subtitle-dark.png" />
|
||||
<img src="docs/subtitle-dark.png" alt="your trip. your plan." height="28" />
|
||||
<img src="docs/subtitle-dark.png" alt="Your trips. Your plan. Your server." height="28" />
|
||||
</picture>
|
||||
|
||||
A self-hosted, real-time collaborative travel planner — with maps, budgets, packing lists, a journal, and AI built in.
|
||||
@@ -21,84 +22,79 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker" src="https://img.shields.io/badge/Docker-ready-2496ED?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://sonarcloud.io/project/overview?id=liketrek_TREK"><img alt="Sonar Quality Gate" src="https://img.shields.io/sonar/quality_gate/liketrek_TREK?server=https%3A%2F%2Fsonarcloud.io&style=for-the-badge" /></a>
|
||||
|
||||
<img alt="GitHub Actions Workflow Status" src="https://img.shields.io/github/actions/workflow/status/liketrek/TREK/test.yml?branch=main&style=for-the-badge">
|
||||
|
||||
<a href="https://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://kanban.pakulat.org/shared/I4wxF6inOOMB0C6hH6kQm3efyNxFjwyI"><img alt="Roadmap" src="https://img.shields.io/badge/Roadmap-view-0EA5E9?style=for-the-badge" /></a>
|
||||
|
||||
<br />
|
||||
<a href="https://ko-fi.com/mauriceboe"><img alt="Ko-fi" src="https://img.shields.io/badge/Ko--fi-support-FF5E5B?style=for-the-badge" /></a>
|
||||
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/liketrek/trek?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/liketrek/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.webp" alt="TREK, a tour of the app" width="100%" />
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What you get
|
||||
|
||||
<picture>
|
||||
<source media="(max-width: 700px)" srcset="docs/tiles/grid-mobile.svg" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/tiles/grid-desktop-dark.svg" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="Plan, track, share" width="100%" />
|
||||
<img src="docs/tiles/grid-desktop.svg" alt="TREK feature tiles" width="100%" />
|
||||
</picture>
|
||||
|
||||
<details>
|
||||
<summary><b>See all features</b></summary>
|
||||
|
||||
<br />
|
||||
|
||||
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.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧭 Planning
|
||||
#### 🧭 Trip planning
|
||||
|
||||
- **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
|
||||
- **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)
|
||||
- **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
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧳 Bookings and money
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **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
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, 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
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -107,71 +103,62 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
#### 👥 Collaboration
|
||||
|
||||
- **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
|
||||
- **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
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 🧩 Plugins
|
||||
#### 📱 Mobile & PWA
|
||||
|
||||
- **Third-party plugins**: install from the TREK registry or sideload a zip, switch on per instance, with their own pages under `/plugins/<id>`
|
||||
- **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
|
||||
- **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
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### 📱 Mobile and offline
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **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
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, 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
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
#### ⚙️ Admin, accounts and security
|
||||
#### 🤖 AI / MCP
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -181,26 +168,6 @@ Most of what follows is an addon an admin switches on or off. Lists, Costs, Docu
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
<a href="docs/screenshots/showcase-desktop-1.webp"><img src="docs/screenshots/showcase-desktop-1.webp" alt="Atlas · dashboard · trip planner" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-mobile.webp"><img src="docs/screenshots/showcase-mobile.webp" alt="Mobile PWA · dashboard, day plan, map, costs" width="100%" /></a>
|
||||
<a href="docs/screenshots/showcase-desktop-2.webp"><img src="docs/screenshots/showcase-desktop-2.webp" alt="Collections · journey journal · costs" width="100%" /></a>
|
||||
</div>
|
||||
|
||||
<br />
|
||||
|
||||
## 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.
|
||||
|
||||
<br />
|
||||
|
||||
## Get started in 30 seconds
|
||||
|
||||
```bash
|
||||
@@ -213,7 +180,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a>
|
||||
· <a href="#docker-compose-production">Docker Compose</a> · <a href="#helm-kubernetes">Helm / Kubernetes</a> · <a href="#install-as-app-pwa">Install as PWA</a> · <a href="#reverse-proxy">Reverse Proxy</a> ·
|
||||
|
||||
</div>
|
||||
|
||||
@@ -224,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -235,35 +202,82 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="docker-compose-production">Docker Compose (production)</h2>
|
||||
|
||||
The repository ships a ready-to-use [`docker-compose.yml`](docker-compose.yml)
|
||||
with secure defaults and every option documented inline. Download it, then:
|
||||
<details>
|
||||
<summary>Full compose example with secure defaults</summary>
|
||||
|
||||
```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:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
See [Install with Docker Compose](https://github.com/liketrek/TREK/wiki/Install-Docker-Compose)
|
||||
for the full walkthrough.
|
||||
**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 Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<br />
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
@@ -279,17 +293,38 @@ TREK then launches fullscreen with its own icon, just like a native app.
|
||||
|
||||
## Updating
|
||||
|
||||
See [Updating](https://github.com/liketrek/TREK/wiki/Updating) — Docker Compose,
|
||||
Docker run, Helm, Portainer, Unraid and Proxmox, plus the encryption-key note.
|
||||
**Docker Compose:**
|
||||
|
||||
<br />
|
||||
```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.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
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).
|
||||
|
||||
<h2 id="reverse-proxy">Reverse Proxy</h2>
|
||||
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
@@ -327,19 +362,6 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Only needed if you use the MCP addon. Responses are Server-Sent Events,
|
||||
# so buffering must be off or tool results arrive late.
|
||||
location /mcp {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -362,18 +384,58 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Every variable, its default and what it does: see
|
||||
[Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables).
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
## Star History
|
||||
<br />
|
||||
|
||||
<a href="https://www.star-history.com/?repos=liketrek%2FTREK&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&theme=dark&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=liketrek/TREK&type=date&legend=bottom-right&sealed_token=tW9agibKugOyPk0yPURgSdnizM7K72zbJENGD9iyk24ZcwYHtCUn-hk9jh1gdacO0O7xJ3Io7LaNrwYD3mChzcpzZIqDC8_HevYG1qnT0XNTmN6Ez4UZrQ" />
|
||||
</picture>
|
||||
</a>
|
||||
| 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` |
|
||||
| `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` | `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 |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express 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` |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
|
||||
|
||||
</details>
|
||||
|
||||
<br />
|
||||
|
||||
## 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
|
||||
|
||||
<br />
|
||||
|
||||
## Data sources
|
||||
|
||||
@@ -382,8 +444,7 @@ 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.
|
||||
|
||||
<br />
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/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"
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/liketrek/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/liketrek/TREK</WebPage>
|
||||
<Forum>https://github.com/liketrek/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
+3
-9
@@ -8,20 +8,18 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
- Health checks on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
> **Note:** `chart.liketrek.com` is a custom domain (CNAME) for the GitHub Pages site at `https://liketrek.github.io/TREK` — both URLs serve the same repository. The github.io URL keeps working (it redirects to `chart.liketrek.com`), but the custom domain is the canonical one to use.
|
||||
|
||||
## Usage
|
||||
|
||||
Or install directly from the local chart:
|
||||
@@ -41,15 +39,11 @@ See `values.yaml` for more options.
|
||||
|
||||
## Notes
|
||||
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||
- PVCs use the cluster's default StorageClass. Set `persistence.data.storageClassName` and/or `persistence.uploads.storageClassName` to bind a specific class.
|
||||
- To use your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`. The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- PVCs require a default StorageClass or specify one as needed.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
- Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically.
|
||||
- `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.3.1
|
||||
version: 3.0.22
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.3.1"
|
||||
appVersion: "3.0.22"
|
||||
|
||||
@@ -21,9 +21,3 @@
|
||||
|
||||
4. Only one method should be used at a time. If both `generateEncryptionKey` and `existingSecret` are
|
||||
set, `existingSecret` takes precedence. Ensure the referenced secret and key exist in the namespace.
|
||||
|
||||
5. Persistence:
|
||||
- To bind your own PVCs, set `persistence.data.existingClaim` and/or `persistence.uploads.existingClaim`.
|
||||
The other values for that volume (size, storageClassName, annotations) are then ignored.
|
||||
- With `persistence.enabled=false` the volumes use an emptyDir — storage is ephemeral and is lost
|
||||
when the pod restarts. Use only for testing.
|
||||
|
||||
@@ -13,9 +13,6 @@ data:
|
||||
{{- if .Values.env.LOG_LEVEL }}
|
||||
LOG_LEVEL: {{ .Values.env.LOG_LEVEL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_WIKI_DIR }}
|
||||
TREK_WIKI_DIR: {{ .Values.env.TREK_WIKI_DIR | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||
{{- end }}
|
||||
@@ -31,21 +28,12 @@ data:
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION }}
|
||||
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_INTERNAL_NETWORK }}
|
||||
ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.ALLOW_LINK_LOCAL_IPS }}
|
||||
ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OIDC_ISSUER }}
|
||||
OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }}
|
||||
{{- end }}
|
||||
@@ -76,24 +64,3 @@ data:
|
||||
{{- if .Values.env.MCP_RATE_LIMIT }}
|
||||
MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.NOMINATIM_URL }}
|
||||
NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_URL }}
|
||||
OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.OVERPASS_TIMEOUT_MS }}
|
||||
OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }}
|
||||
{{- end }}
|
||||
{{- /* Tested for presence rather than truth: the one value this switch gets set to is
|
||||
false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would
|
||||
otherwise drop the key and leave the index on. */}}
|
||||
{{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }}
|
||||
TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TREK_PLACES_URL }}
|
||||
TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.AMAP_API_BASE }}
|
||||
AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -6,12 +6,6 @@ metadata:
|
||||
app: {{ include "trek.name" . }}
|
||||
spec:
|
||||
replicas: 1
|
||||
# TREK is a single-writer SQLite app on a ReadWriteOnce PVC, so the default
|
||||
# RollingUpdate would start a second pod holding the same volume before the old one
|
||||
# exits — a Multi-Attach deadlock, or two processes on one travel.db. Recreate tears
|
||||
# the old pod down first. Override to RollingUpdate only with a ReadWriteMany volume.
|
||||
strategy:
|
||||
type: {{ .Values.updateStrategy | default "Recreate" }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: {{ include "trek.name" . }}
|
||||
@@ -69,49 +63,27 @@ spec:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: OIDC_CLIENT_SECRET
|
||||
optional: true
|
||||
- name: UNSPLASH_ACCESS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: UNSPLASH_ACCESS_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_KEY
|
||||
optional: true
|
||||
- name: AMAP_API_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||
key: AMAP_API_SECRET
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-data" (include "trek.fullname" .)) .Values.persistence.data.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
claimName: {{ include "trek.fullname" . }}-data
|
||||
- name: uploads
|
||||
{{- if .Values.persistence.enabled }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ default (printf "%s-uploads" (include "trek.fullname" .)) .Values.persistence.uploads.existingClaim }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
claimName: {{ include "trek.fullname" . }}-uploads
|
||||
|
||||
@@ -1,42 +1,26 @@
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.data.existingClaim) }}
|
||||
{{- if .Values.persistence.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-data
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.data.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.data.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.data.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.persistence.enabled (not .Values.persistence.uploads.existingClaim) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "trek.fullname" . }}-uploads
|
||||
labels:
|
||||
app: {{ include "trek.name" . }}
|
||||
{{- with .Values.persistence.uploads.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
{{- with .Values.persistence.uploads.storageClassName }}
|
||||
storageClassName: {{ . | quote }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.persistence.uploads.size }}
|
||||
|
||||
@@ -17,15 +17,6 @@ data:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }}
|
||||
@@ -53,13 +44,4 @@ stringData:
|
||||
{{- if .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
OIDC_CLIENT_SECRET: {{ .Values.secretEnv.OIDC_CLIENT_SECRET }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_KEY }}
|
||||
AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }}
|
||||
{{- end }}
|
||||
{{- if .Values.secretEnv.AMAP_API_SECRET }}
|
||||
AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -4,11 +4,6 @@ image:
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
# Deployment update strategy. Recreate is the safe default for the single-writer SQLite
|
||||
# DB on a ReadWriteOnce volume (the old pod is torn down before the new one starts).
|
||||
# Set to RollingUpdate only if you back the data volume with ReadWriteMany storage.
|
||||
updateStrategy: Recreate
|
||||
|
||||
# Optional image pull secrets for private registries
|
||||
imagePullSecrets: []
|
||||
# - name: my-registry-secret
|
||||
@@ -24,12 +19,6 @@ env:
|
||||
# Timezone for logs, reminders, and cron jobs (e.g. Europe/Berlin).
|
||||
# LOG_LEVEL: "info"
|
||||
# "info" = concise user actions, "debug" = verbose details.
|
||||
# TREK_WIKI_DIR: "/app/wiki"
|
||||
# Where the in-app Help pages (/help) read their content from. Leave unset: the
|
||||
# image ships the wiki at /app/wiki and finds it automatically, so Help matches
|
||||
# the version you are running. Only set this to serve your own docs from a mounted
|
||||
# volume. If the path does not exist, Help falls back to fetching the public GitHub
|
||||
# wiki, which needs egress and tracks the latest release rather than your version.
|
||||
# DEFAULT_LANGUAGE: "en"
|
||||
# Default language on the login page for users with no saved preference.
|
||||
# Browser/OS language is auto-detected first; this is the fallback when no match is found.
|
||||
@@ -45,18 +34,11 @@ env:
|
||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# SESSION_DURATION: "24h"
|
||||
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||
# SESSION_DURATION_REMEMBER: "30d"
|
||||
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||
# TRUST_PROXY: "1"
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
# Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address.
|
||||
# Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked.
|
||||
# ALLOW_LINK_LOCAL_IPS: ""
|
||||
# Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway.
|
||||
# 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup.
|
||||
# OIDC_ISSUER: ""
|
||||
# OpenID Connect provider URL.
|
||||
# OIDC_CLIENT_ID: ""
|
||||
@@ -81,33 +63,6 @@ env:
|
||||
# Max MCP API requests per user per minute. Defaults to 300.
|
||||
# MCP_MAX_SESSION_PER_USER: "20"
|
||||
# Max concurrent MCP sessions per user. Defaults to 20.
|
||||
# NOMINATIM_URL: ""
|
||||
# Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region
|
||||
# fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs
|
||||
# the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either
|
||||
# way. Defaults to https://nominatim.openstreetmap.org.
|
||||
# OVERPASS_URL: ""
|
||||
# Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled
|
||||
# public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable
|
||||
# from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored.
|
||||
# OVERPASS_TIMEOUT_MS: "25000"
|
||||
# Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a
|
||||
# lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance.
|
||||
# Defaults to 25000.
|
||||
# TREK_PLACES_ENABLED: "false"
|
||||
# Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads)
|
||||
# asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that
|
||||
# host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send
|
||||
# nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided
|
||||
# here. Defaults to on.
|
||||
# TREK_PLACES_URL: ""
|
||||
# Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from
|
||||
# configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK.
|
||||
# Defaults to https://places.liketrek.com.
|
||||
# AMAP_API_BASE: ""
|
||||
# Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of
|
||||
# restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into
|
||||
# Admin > Settings > API Keys. Defaults to https://restapi.amap.com.
|
||||
|
||||
|
||||
# Secret environment variables stored in a Kubernetes Secret.
|
||||
@@ -127,21 +82,6 @@ secretEnv:
|
||||
ADMIN_PASSWORD: ""
|
||||
# OIDC client secret — set together with env.OIDC_ISSUER and env.OIDC_CLIENT_ID.
|
||||
OIDC_CLIENT_SECRET: ""
|
||||
# Optional Unsplash Access Key for trip-cover and place-image search.
|
||||
# Without one, TREK uses Unsplash's unauthenticated endpoint, which some
|
||||
# datacenter/VPS IPs (including many Kubernetes clusters) are blocked from.
|
||||
# Get a free key at https://unsplash.com/developers. Can also be set per-admin
|
||||
# in Admin > Settings; this value overrides that. Leave empty to disable.
|
||||
UNSPLASH_ACCESS_KEY: ""
|
||||
# Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland
|
||||
# China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is
|
||||
# a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the
|
||||
# key from the admin panel, or none.
|
||||
AMAP_API_KEY: ""
|
||||
# The private secret (数字签名) of that key. Required exactly when the key was created
|
||||
# with signing enabled, because such a key rejects every unsigned request. Leave empty
|
||||
# otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE.
|
||||
AMAP_API_SECRET: ""
|
||||
|
||||
# If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades
|
||||
generateEncryptionKey: false
|
||||
@@ -151,40 +91,11 @@ existingSecret: ""
|
||||
existingSecretKey: ENCRYPTION_KEY
|
||||
|
||||
persistence:
|
||||
# When disabled, volumes fall back to an ephemeral emptyDir (data lost on pod restart).
|
||||
enabled: true
|
||||
data:
|
||||
size: 1Gi
|
||||
# Leave empty to use the cluster's default StorageClass; set to bind a specific class.
|
||||
storageClassName: ""
|
||||
# Bind an existing PVC. The other values (size, storageClassName, annotations) are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
uploads:
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
# Specify an existing PVC to bind. The other values are then ignored.
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
|
||||
@@ -3,6 +3,3 @@ e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint # eslint . (lint:check in CI)
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (local only; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
|
||||
```
|
||||
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
|
||||
|
||||
## Page pattern (enforced — `lint:pages` fails otherwise)
|
||||
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
|
||||
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Rules for new code
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
|
||||
|
||||
## Big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop ↔ mobile shells and the SonarQube duplication gate
|
||||
|
||||
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
|
||||
@@ -1,5 +1,4 @@
|
||||
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.
|
||||
@@ -28,12 +27,16 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
|
||||
|
||||
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||
|
||||
// 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)
|
||||
// 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)
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: stateFile })
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Trip lifecycle (core): from the dashboard, open the new-trip modal, name the
|
||||
// trip, submit, and confirm it shows up on the dashboard. Exercises the whole
|
||||
@@ -8,23 +7,18 @@ import { dismissSystemNotices } from './helpers'
|
||||
test('create a trip and see it on the dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// The "+ New Trip" card is always rendered in the default (planned) filter.
|
||||
await page.locator('.add-trip-card').click()
|
||||
|
||||
// Scope to the shared Modal (.trek-modal-backdrop — namespaced so content blockers
|
||||
// don't hide a generic .modal-backdrop). Its form has no in-form submit button (the
|
||||
// primary action lives in the footer), so click it explicitly rather than pressing
|
||||
// Enter. The Create button is the slate primary button; Cancel is the bordered one.
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
// Scope to the shared Modal (.modal-backdrop). Its form has no in-form submit
|
||||
// button (the primary action lives in the footer), so click it explicitly
|
||||
// rather than pressing Enter. The Create button is the slate primary button;
|
||||
// Cancel is the bordered one.
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Trip ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await expect(page.getByText(title).first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* 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 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, appearTimeoutMs = 3_000): Promise<void> {
|
||||
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)
|
||||
}
|
||||
|
||||
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test, expect, devices } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Tablet regression guard for #1432 — the places list must scroll under a touch swipe.
|
||||
//
|
||||
// A tablet is a coarse-pointer device at a *desktop* viewport width, so the width-based
|
||||
// "is this mobile" check that 3.2.1 shipped left `draggable` armed on iPad: the swipe
|
||||
// became an HTML5 drag and raised the drop-to-import overlay instead of scrolling. Drag
|
||||
// is now gated on `(pointer: coarse)` (useIsTouch), and only a real device context proves
|
||||
// it — a jsdom unit test cannot express "coarse pointer at 834px".
|
||||
//
|
||||
// Needs WebKit (`npx playwright install webkit`, plus libmanette-0.2-0 and libwoff1 on
|
||||
// Debian/Ubuntu). WebKit is the right engine here, not a nicety: every browser on iPadOS
|
||||
// is WebKit underneath, which is why the reporter saw this in all three they tried.
|
||||
test.use({ ...devices['iPad Pro 11'] })
|
||||
|
||||
test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const createBtn = page.getByRole('button', { name: 'Create New Trip' })
|
||||
await expect(createBtn).toBeVisible()
|
||||
const title = `iPad 1432 ${Date.now()}`
|
||||
await page.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await createBtn.click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
|
||||
// Seed enough places for the list to overflow and actually need scrolling.
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name: `Place ${i}`, lat: 48.85 + i * 0.01, lng: 2.35 + i * 0.01 },
|
||||
})
|
||||
expect(res.ok(), `seed place ${i}`).toBeTruthy()
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// An iPad is inside the 768-1023px band, where only one side panel is open at a
|
||||
// time so the map keeps a usable width (#2247). The places list is one tap away.
|
||||
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
|
||||
// "Plan" button of its own.
|
||||
await page.locator('button[aria-label="Places"]').click()
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
// width. If either is wrong, everything below proves nothing.
|
||||
const env = await page.evaluate(() => ({
|
||||
coarse: window.matchMedia('(pointer: coarse)').matches,
|
||||
width: window.innerWidth,
|
||||
}))
|
||||
expect(env.coarse, 'iPad reports a coarse primary pointer').toBe(true)
|
||||
expect(env.width, 'iPad sits above the 768px "mobile" breakpoint').toBeGreaterThanOrEqual(768)
|
||||
|
||||
// 1. Rows must not be draggable — a draggable row is what swallowed the scroll gesture.
|
||||
const row = page.locator('div[draggable]').filter({ hasText: 'Place 1' }).first()
|
||||
await expect(row).toHaveAttribute('draggable', 'false')
|
||||
|
||||
// 2. The list must scroll, and no drop-to-import overlay may appear.
|
||||
const scroller = page.locator('div[draggable]').first().locator('xpath=ancestor::div[@class="trek-stagger"]')
|
||||
const before = await scroller.evaluate(el => el.scrollTop)
|
||||
const box = (await scroller.boundingBox())!
|
||||
await page.touchscreen.tap(box.x + box.width / 2, box.y + 40)
|
||||
await scroller.evaluate(el => el.scrollBy(0, 200))
|
||||
const after = await scroller.evaluate(el => el.scrollTop)
|
||||
expect(after, 'places list scrolled').toBeGreaterThan(before)
|
||||
await expect(page.getByText('Drop to import')).toHaveCount(0)
|
||||
|
||||
// 3. Drag being off means the arrow buttons are the only reorder affordance left —
|
||||
// they must be visible (they were opacity:0 above 767px).
|
||||
const arrowOpacity = await page.evaluate(() => {
|
||||
const el = document.querySelector('.reorder-buttons')
|
||||
return el ? getComputedStyle(el).opacity : 'absent'
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan and left its
|
||||
// tab as the way back.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// The day-plan reorder arrows are hover-revealed on desktop. The rule that did that was
|
||||
// dead for a long time — it targeted `.place-row .reorder-btns`, neither of which exists
|
||||
// (the component renders `.reorder-buttons` inside an unclassed row), so the buttons sat
|
||||
// at opacity:0 with no way to reveal them.
|
||||
//
|
||||
// That is not merely "invisible": opacity:0 still hit-tests, so every itinerary row and
|
||||
// note carried an invisible, fully clickable target that silently reordered the trip.
|
||||
// These cases pin both halves — hidden means non-interactive, hover means visible.
|
||||
test('desktop: reorder arrows are hidden-and-inert until the row is hovered', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
const title = `Reorder ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
await page.getByText(title).first().click()
|
||||
await expect(page).toHaveURL(/\/trips\/\d+/)
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// Two places on day 1, so the day plan renders rows carrying reorder arrows.
|
||||
const tripId = page.url().match(/\/trips\/(\d+)/)![1]
|
||||
const daysRes = await (await page.request.get(`/api/trips/${tripId}/days`)).json()
|
||||
const dayId = (daysRes.days ?? daysRes)[0].id
|
||||
for (const name of ['Alpha', 'Beta']) {
|
||||
const res = await page.request.post(`/api/trips/${tripId}/places`, {
|
||||
data: { name, lat: 48.85, lng: 2.35 },
|
||||
})
|
||||
const body = await res.json()
|
||||
await page.request.post(`/api/trips/${tripId}/days/${dayId}/assignments`, {
|
||||
data: { place_id: body.place?.id ?? body.id },
|
||||
})
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
const row = page.locator('.dp-row').filter({ hasText: 'Alpha' }).first()
|
||||
await expect(row).toBeVisible({ timeout: 20_000 })
|
||||
const arrows = row.locator('.reorder-buttons')
|
||||
|
||||
// Unhovered: invisible AND inert — a click there must not land on the button.
|
||||
const idle = await arrows.evaluate(el => {
|
||||
const cs = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)
|
||||
return { opacity: cs.opacity, hitsArrow: !!hit?.closest('.reorder-buttons') }
|
||||
})
|
||||
expect(idle.opacity, 'arrows hidden until hover').toBe('0')
|
||||
expect(idle.hitsArrow, 'hidden arrows must not swallow clicks').toBe(false)
|
||||
|
||||
// Hovered: revealed and clickable.
|
||||
await row.hover()
|
||||
await expect(arrows).toHaveCSS('opacity', '1')
|
||||
await expect(arrows).toHaveCSS('pointer-events', 'auto')
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { test, expect } from './shot'
|
||||
|
||||
/**
|
||||
* Unauthenticated surfaces. `storageState: undefined` drops the admin session
|
||||
* this project otherwise inherits, so these render as a logged-out visitor sees
|
||||
* them — which is the entire point of the login and registration pages.
|
||||
*/
|
||||
test.use({ storageState: undefined })
|
||||
|
||||
test('login page', async ({ page, shot }) => {
|
||||
await page.goto('/login')
|
||||
await expect(page.locator('input[type="email"]')).toBeVisible()
|
||||
await shot.page_('Login')
|
||||
})
|
||||
|
||||
test('registration page', async ({ page, shot }) => {
|
||||
await page.goto('/register')
|
||||
await page.waitForTimeout(500)
|
||||
await shot.page_('Registration')
|
||||
})
|
||||
|
||||
test('forgot password', async ({ page, shot }) => {
|
||||
await page.goto('/forgot-password')
|
||||
await page.waitForTimeout(500)
|
||||
await shot.page_('PasswordReset')
|
||||
})
|
||||
@@ -1,69 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import type { Page, Locator } from '@playwright/test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Collab surfaces, one capture each.
|
||||
*
|
||||
* Until now a single Collab.png illustrated four different wiki pages — chat,
|
||||
* notes, polls and the What's Next widget — so at most one of them showed the
|
||||
* feature its page described.
|
||||
*
|
||||
* The Collab view is NOT tabbed: CollabPanel renders chat in a fixed 380px left
|
||||
* column and the other panels beside it, all visible at once (CollabPanel.tsx:94).
|
||||
* So each capture targets its own card element rather than clicking a tab.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The panel card containing a given piece of seeded content — see cardClass in
|
||||
* CollabPanel.tsx:20.
|
||||
*
|
||||
* Matching on content rather than the panel heading is deliberate: the headings
|
||||
* render uppercase through CSS while the DOM text is "Notes" / "Polls", and
|
||||
* those same words also appear in the mobile tab bar, so a heading match is both
|
||||
* wrong-cased and ambiguous.
|
||||
*/
|
||||
function card(page: Page, contains: string): Locator {
|
||||
return page
|
||||
.locator('div.bg-surface-card.rounded-2xl')
|
||||
.filter({ hasText: contains })
|
||||
.last()
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Collab', exact: true }).first().click()
|
||||
await page.waitForTimeout(1200)
|
||||
})
|
||||
|
||||
test('collab chat', async ({ page, shot }) => {
|
||||
// Seeded as three different people; a single-voice log would misrepresent it.
|
||||
// The chat auto-scrolls to the newest message, so assert on the last line of
|
||||
// the seeded conversation rather than the first — the first is off-screen.
|
||||
await expect(page.getByText('kaiseki', { exact: false }).first()).toBeVisible()
|
||||
await shot.element('CollabChat', card(page, 'kaiseki'))
|
||||
})
|
||||
|
||||
test('collab notes', async ({ page, shot }) => {
|
||||
await expect(page.getByText('Rail passes', { exact: false })).toBeVisible()
|
||||
await shot.element('CollabNotes', card(page, 'Rail passes'))
|
||||
})
|
||||
|
||||
test('collab polls', async ({ page, shot }) => {
|
||||
await expect(page.getByText('free for Nara', { exact: false })).toBeVisible()
|
||||
await shot.element('CollabPolls', card(page, 'free for Nara'))
|
||||
})
|
||||
|
||||
test("what's next widget", async ({ page, shot }) => {
|
||||
await shot.element('WhatsNext', card(page, "What's Next"))
|
||||
})
|
||||
|
||||
test('collab overview', async ({ page, shot }) => {
|
||||
await shot.page_('Collab')
|
||||
})
|
||||
@@ -1,88 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Detail pages and the surfaces that need a couple of clicks to reach.
|
||||
*
|
||||
* Each capture asserts something specific to the surface before shooting, so a
|
||||
* navigation that quietly lands on a fallback (or an addon that is off) fails
|
||||
* the run instead of producing a screenshot of the wrong screen.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test('collection detail', async ({ page, shot }) => {
|
||||
test.skip(!seed.collectionId, 'collections addon unavailable during seed')
|
||||
await page.goto(`/collections/${seed.collectionId}`)
|
||||
await clearNotices(page)
|
||||
await shot.page_('CollectionDetail')
|
||||
})
|
||||
|
||||
test('journey detail', async ({ page, shot }) => {
|
||||
test.skip(!seed.journeyId, 'journey addon unavailable during seed')
|
||||
await page.goto(`/journey/${seed.journeyId}`)
|
||||
await clearNotices(page)
|
||||
await shot.page_('JourneyDetail')
|
||||
})
|
||||
|
||||
test('mcp access — admin', async ({ page, shot }) => {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'MCP Access', exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
await shot.page_('MCPAccess')
|
||||
})
|
||||
|
||||
test('two-factor setup', async ({ page, shot }) => {
|
||||
await page.goto('/settings')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Account', exact: true }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
// The enrolment flow is behind a button whose label varies with state; match
|
||||
// loosely and fall back to capturing the tab itself.
|
||||
const enable = page.getByRole('button', { name: /two-factor|2fa|authenticator/i }).first()
|
||||
if (await enable.isVisible().catch(() => false)) {
|
||||
await enable.click()
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
await shot.page_('2FA')
|
||||
})
|
||||
|
||||
/**
|
||||
* Settle-up.
|
||||
*
|
||||
* WARNING for anyone extending this file: the "Settle up" button in the Costs
|
||||
* toolbar is not a view — it RECORDS the settling transfers. An earlier version
|
||||
* of this test clicked it, which zeroed every balance and left the capture
|
||||
* showing "Everyone's square". Because all screenshot specs share one database
|
||||
* and this file sorts before planner.shot.ts, it also poisoned Costs.png in the
|
||||
* same run.
|
||||
*
|
||||
* Screenshot specs must not mutate state. Capture the "Add payment" dialog
|
||||
* instead — same surface, no side effect — and close it again.
|
||||
*/
|
||||
test('costs — record a settle-up payment', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: 'Costs', exact: true }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
const addPayment = page.getByRole('button', { name: /add payment/i }).first()
|
||||
test.skip(!(await addPayment.isVisible().catch(() => false)), 'no add-payment entry point rendered')
|
||||
await addPayment.click()
|
||||
await page.waitForTimeout(700)
|
||||
|
||||
const modal = page.locator('.trek-modal-backdrop > div').first()
|
||||
await expect(modal).toBeVisible()
|
||||
await shot.element('CostsSettleUp', modal)
|
||||
})
|
||||
|
||||
test('trip files', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}/files`)
|
||||
await clearNotices(page)
|
||||
await expect(page).toHaveURL(/files/)
|
||||
await shot.page_('Documents')
|
||||
})
|
||||
@@ -1,42 +0,0 @@
|
||||
import { test, clearNotices, expect } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Modals and dialogs.
|
||||
*
|
||||
* Captured as element screenshots (not full page) so the wiki gets the dialog
|
||||
* itself rather than a dimmed backdrop with a small box in the middle. Each one
|
||||
* asserts the dialog is actually open first — a missed click would otherwise
|
||||
* silently produce a screenshot of the page behind it.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
/**
|
||||
* The shared Modal (client/src/components/shared/Modal.tsx) sets neither
|
||||
* role="dialog" nor aria-modal, so there is no accessible role to query — the
|
||||
* backdrop class is the only stable hook. Target its child, which is the panel
|
||||
* itself, so the capture excludes the dimmed backdrop.
|
||||
*/
|
||||
function dialog(page: import('@playwright/test').Page) {
|
||||
return page.locator('.trek-modal-backdrop > div').first()
|
||||
}
|
||||
|
||||
test('create trip modal — with the new currency field', async ({ page, shot }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: /new trip/i }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('TripCreate', dialog(page))
|
||||
})
|
||||
|
||||
test('share dialog', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
await page.getByRole('button', { name: /share/i }).first().click()
|
||||
await expect(dialog(page)).toBeVisible()
|
||||
await shot.element('Share', dialog(page))
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Top-level navigable surfaces. One capture per route; anything that needs a
|
||||
* dialog opened or a tab clicked lives in its own spec so a failure there
|
||||
* cannot take these down with it.
|
||||
*
|
||||
* Names are the target filenames in wiki/assets/ — see docs/screenshot-map.md
|
||||
* for which wiki page consumes which file.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number; collectionId?: number; journeyId?: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
test('dashboard', async ({ page, shot }) => {
|
||||
await page.goto('/dashboard')
|
||||
await clearNotices(page)
|
||||
await shot.page_('DashboardWidgets')
|
||||
})
|
||||
|
||||
test('trip planner', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await shot.page_('TripPlanner')
|
||||
})
|
||||
|
||||
test('atlas', async ({ page, shot }) => {
|
||||
await page.goto('/atlas')
|
||||
await shot.page_('Atlas')
|
||||
})
|
||||
|
||||
test('vacay', async ({ page, shot }) => {
|
||||
await page.goto('/vacay')
|
||||
await shot.page_('Vacay')
|
||||
})
|
||||
|
||||
test('collections', async ({ page, shot }) => {
|
||||
await page.goto('/collections')
|
||||
await shot.page_('Collections')
|
||||
})
|
||||
|
||||
test('journey', async ({ page, shot }) => {
|
||||
await page.goto('/journey')
|
||||
await shot.page_('Journey')
|
||||
})
|
||||
|
||||
test('notifications inbox', async ({ page, shot }) => {
|
||||
await page.goto('/notifications')
|
||||
await shot.page_('NotificationsInbox')
|
||||
})
|
||||
|
||||
test('in-app help', async ({ page, shot }) => {
|
||||
await page.goto('/help')
|
||||
await shot.page_('HelpInApp')
|
||||
})
|
||||
|
||||
test('files', async ({ page, shot }) => {
|
||||
await page.goto(`/trips/${seed.tripId}/files`)
|
||||
await shot.page_('Files')
|
||||
})
|
||||
@@ -1,47 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Trip-planner tabs and dialogs.
|
||||
*
|
||||
* Tabs are reached by their visible label rather than a test id, deliberately:
|
||||
* if a label is renamed (as Budget → Costs was in 3.3.0) this run fails loudly
|
||||
* instead of silently capturing the wrong panel — which is exactly how the
|
||||
* current wiki ended up with screenshots the text contradicts.
|
||||
*/
|
||||
|
||||
const seed = JSON.parse(
|
||||
readFileSync(path.join(process.cwd(), 'e2e', '.tmp', 'seed.json'), 'utf8'),
|
||||
) as { tripId: number }
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto(`/trips/${seed.tripId}`)
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
async function openTab(page: import('@playwright/test').Page, label: string) {
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test('costs panel', async ({ page, shot }) => {
|
||||
await openTab(page, 'Costs')
|
||||
await shot.page_('Costs')
|
||||
})
|
||||
|
||||
test('lists — packing', async ({ page, shot }) => {
|
||||
await openTab(page, 'Lists')
|
||||
await shot.page_('PackingList')
|
||||
})
|
||||
|
||||
test('transports', async ({ page, shot }) => {
|
||||
await openTab(page, 'Transports')
|
||||
await shot.page_('Transports')
|
||||
})
|
||||
|
||||
test('bookings', async ({ page, shot }) => {
|
||||
await openTab(page, 'Book')
|
||||
await shot.page_('Bookings')
|
||||
})
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Moves captured screenshots from the staging directory into wiki/assets/,
|
||||
// downscaling and re-encoding on the way.
|
||||
//
|
||||
// Captures are taken at 1440px CSS width with deviceScaleFactor 2, i.e. 2880px
|
||||
// of raw pixels. The wiki renders images at roughly 800–1000px, so shipping
|
||||
// 2880px costs ~10x the bytes for detail nobody sees — that is how the existing
|
||||
// assets reached 26 MB (one GIF alone is 9.1 MB). 1600px keeps the image sharp
|
||||
// on HiDPI displays at the size it is actually shown.
|
||||
//
|
||||
// Usage: node e2e/screenshots/promote.mjs [--dry]
|
||||
import sharp from 'sharp'
|
||||
import { readdirSync, mkdirSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const SRC = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
|
||||
const DEST = path.join(process.cwd(), '..', 'wiki', 'assets')
|
||||
const MAX_WIDTH = 1600
|
||||
const dry = process.argv.includes('--dry')
|
||||
|
||||
mkdirSync(DEST, { recursive: true })
|
||||
|
||||
const files = readdirSync(SRC).filter(f => f.endsWith('.png'))
|
||||
if (!files.length) {
|
||||
console.error(`No screenshots in ${SRC} — run \`npm run shots\` first.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let before = 0
|
||||
let after = 0
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const src = path.join(SRC, file)
|
||||
const dest = path.join(DEST, file)
|
||||
const srcBytes = statSync(src).size
|
||||
before += srcBytes
|
||||
|
||||
const img = sharp(src)
|
||||
const { width } = await img.metadata()
|
||||
|
||||
const pipeline = sharp(src)
|
||||
.resize({ width: Math.min(width ?? MAX_WIDTH, MAX_WIDTH), withoutEnlargement: true })
|
||||
.png({ compressionLevel: 9, effort: 10 })
|
||||
|
||||
const buf = await pipeline.toBuffer()
|
||||
after += buf.length
|
||||
|
||||
const pct = Math.round((1 - buf.length / srcBytes) * 100)
|
||||
console.log(
|
||||
`${dry ? '[dry] ' : ''}${file.padEnd(28)} ${kb(srcBytes).padStart(8)} → ${kb(buf.length).padStart(8)} (-${pct}%)`,
|
||||
)
|
||||
if (!dry) await sharp(buf).toFile(dest)
|
||||
}
|
||||
|
||||
console.log(`\n${files.length} files: ${kb(before)} → ${kb(after)} (-${Math.round((1 - after / before) * 100)}%)`)
|
||||
if (dry) console.log('Dry run — nothing written. Drop --dry to promote into wiki/assets/.')
|
||||
|
||||
function kb(bytes) {
|
||||
return bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { seedDemoData } from './seed'
|
||||
|
||||
/**
|
||||
* Populates the throwaway E2E database with the demo trip before any screenshot
|
||||
* runs. Its own Playwright project so it executes exactly once, after `setup`
|
||||
* (which produces the authenticated storageState) and before `screenshots`.
|
||||
*
|
||||
* The resulting ids are written to disk because Playwright projects do not
|
||||
* share memory — the capture specs read them back.
|
||||
*/
|
||||
setup('seed the demo trip', async ({ page, playwright }) => {
|
||||
// page.request carries the storageState cookie, so this is authenticated as
|
||||
// the admin. The factory hands the seeder throwaway contexts for the other
|
||||
// members — see the comment in seed.ts on why they must not share one.
|
||||
const result = await seedDemoData(page.request, token =>
|
||||
playwright.request.newContext({
|
||||
baseURL: 'http://localhost:5173',
|
||||
// MUST be explicit: newContext otherwise picks up the project's
|
||||
// storageState, i.e. the admin's trek_session cookie — and the server
|
||||
// reads the cookie BEFORE the Authorization header
|
||||
// (server/src/middleware/auth.ts:9), so every "member" write would be
|
||||
// recorded as the admin while still returning 200.
|
||||
storageState: undefined,
|
||||
extraHTTPHeaders: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.tripId, 'trip was created').toBeTruthy()
|
||||
expect(result.placeIds.length, 'places were created').toBeGreaterThan(0)
|
||||
|
||||
const dir = path.join(process.cwd(), 'e2e', '.tmp')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(path.join(dir, 'seed.json'), JSON.stringify(result, null, 2))
|
||||
})
|
||||
@@ -1,347 +0,0 @@
|
||||
import path from 'node:path'
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Demo data for the documentation screenshots.
|
||||
*
|
||||
* Seeded over the REST API (not the DB) so it exercises the same paths a real
|
||||
* user would and stays honest about validation. The session cookie comes from
|
||||
* the storageState that auth.setup.ts writes, so `page.request` is already
|
||||
* authenticated as the seeded admin.
|
||||
*
|
||||
* Design notes that matter for the screenshots:
|
||||
* - The trip is in **JPY**, deliberately. A EUR trip hides the entire v3.4.0
|
||||
* currency rework (per-trip currency, frozen FX rates, foreign-currency
|
||||
* settle-up) — the reader would see nothing new.
|
||||
* - Two extra members exist so splits, avatars and sharing tiers render with
|
||||
* real names instead of a lonely single-user state.
|
||||
* - Dates sit ~2 months out so "upcoming" surfaces (What's Next, reservations)
|
||||
* have something to show.
|
||||
*/
|
||||
|
||||
const TRIP = {
|
||||
title: 'Autumn in Japan',
|
||||
description: 'Two weeks chasing momiji season from Tokyo down to Kyoto.',
|
||||
start_date: '2026-09-12',
|
||||
end_date: '2026-09-21',
|
||||
currency: 'JPY',
|
||||
reminder_days: 3,
|
||||
}
|
||||
|
||||
const MEMBERS = [
|
||||
{ username: 'mira', email: 'mira@example.com', password: 'DemoSeed12345!', role: 'user' },
|
||||
{ username: 'jonas', email: 'jonas@example.com', password: 'DemoSeed12345!', role: 'user' },
|
||||
]
|
||||
|
||||
/** Real coordinates — the map surfaces are a big part of what we're capturing. */
|
||||
const PLACES = [
|
||||
{ name: 'Senso-ji Temple', lat: 35.7148, lng: 139.7967, address: '2-3-1 Asakusa, Taito City, Tokyo',
|
||||
description: "Tokyo's oldest temple, approached through the Nakamise shopping street.",
|
||||
notes: 'Go before 08:00 — the gate is empty and the light is better.',
|
||||
duration_minutes: 90, price: 0, currency: 'JPY', day: 0 },
|
||||
{ name: 'teamLab Planets', lat: 35.6486, lng: 139.7900, address: '6-1-16 Toyosu, Koto City, Tokyo',
|
||||
description: 'Immersive digital art museum you walk through barefoot.',
|
||||
notes: 'Timed entry — book at least a week ahead.',
|
||||
duration_minutes: 120, price: 3800, currency: 'JPY', day: 0 },
|
||||
{ name: 'Shibuya Crossing', lat: 35.6595, lng: 139.7005, address: 'Shibuya City, Tokyo',
|
||||
description: 'The scramble. Best viewed from the Shibuya Sky observation deck.',
|
||||
duration_minutes: 45, price: 0, currency: 'JPY', day: 1 },
|
||||
{ name: 'Meiji Jingu', lat: 35.6764, lng: 139.6993, address: '1-1 Yoyogikamizonocho, Shibuya City, Tokyo',
|
||||
description: 'Forest shrine in the middle of the city.',
|
||||
duration_minutes: 75, price: 0, currency: 'JPY', day: 1 },
|
||||
{ name: 'Fushimi Inari Taisha', lat: 34.9671, lng: 135.7727, address: '68 Fukakusa Yabunouchicho, Fushimi Ward, Kyoto',
|
||||
description: 'Thousands of vermilion torii gates climbing Mount Inari.',
|
||||
notes: 'The crowds thin out after the first 20 minutes of climbing.',
|
||||
duration_minutes: 150, price: 0, currency: 'JPY', day: 4 },
|
||||
{ name: 'Arashiyama Bamboo Grove', lat: 35.0170, lng: 135.6716, address: 'Ukyo Ward, Kyoto',
|
||||
description: 'Bamboo path leading to the Okochi Sanso villa gardens.',
|
||||
duration_minutes: 60, price: 0, currency: 'JPY', day: 5 },
|
||||
{ name: 'Nishiki Market', lat: 35.0050, lng: 135.7649, address: 'Nakagyo Ward, Kyoto',
|
||||
description: "Five covered blocks of food stalls — 'Kyoto's kitchen'.",
|
||||
notes: 'Come hungry. Try the tamagoyaki.',
|
||||
duration_minutes: 90, price: 2500, currency: 'JPY', day: 5 },
|
||||
]
|
||||
|
||||
const EXPENSES = [
|
||||
{ name: 'Flights FRA → HND', category: 'transport', total_price: 890, currency: 'EUR',
|
||||
expense_date: '2026-09-12', note: 'Booked with miles, taxes only.' },
|
||||
{ name: 'Ryokan in Hakone', category: 'accommodation', total_price: 48000, currency: 'JPY',
|
||||
expense_date: '2026-09-15', note: '2 nights, kaiseki dinner included.' },
|
||||
{ name: 'JR Pass (14 days)', category: 'transport', total_price: 80000, currency: 'JPY',
|
||||
expense_date: '2026-09-12', note: 'Green car, activated on arrival.' },
|
||||
{ name: 'teamLab Planets tickets', category: 'activities', total_price: 11400, currency: 'JPY',
|
||||
expense_date: '2026-09-13' },
|
||||
{ name: 'Dinner at Nishiki', category: 'food', total_price: 7200, currency: 'JPY',
|
||||
expense_date: '2026-09-17' },
|
||||
]
|
||||
|
||||
const PACKING = [
|
||||
{ category: 'Documents', items: ['Passport', 'JR Pass voucher', 'Travel insurance'] },
|
||||
{ category: 'Clothing', items: ['Rain jacket', 'Walking shoes', 'Light layers'] },
|
||||
{ category: 'Electronics', items: ['Type-A adapter', 'Power bank', 'Camera'] },
|
||||
]
|
||||
|
||||
const TODOS = [
|
||||
{ name: 'Book teamLab Planets slot', category: 'Before departure', due_date: '2026-08-15', priority: 2 },
|
||||
{ name: 'Activate JR Pass', category: 'On arrival', due_date: '2026-09-12', priority: 1 },
|
||||
{ name: 'Reserve ryokan dinner', category: 'Before departure', due_date: '2026-08-20' },
|
||||
]
|
||||
|
||||
export interface SeedResult {
|
||||
tripId: number
|
||||
memberIds: number[]
|
||||
dayIds: number[]
|
||||
placeIds: number[]
|
||||
collectionId?: number
|
||||
journeyId?: number
|
||||
}
|
||||
|
||||
/** Throws with the response body on failure — a silent 4xx here would produce
|
||||
* a screenshot of an empty screen, which is worse than a loud crash. */
|
||||
async function call<T>(api: APIRequestContext, method: 'post' | 'put' | 'get' | 'patch',
|
||||
path: string, body?: unknown): Promise<T> {
|
||||
const res = await api[method](path, body === undefined ? {} : { data: body })
|
||||
if (!res.ok()) {
|
||||
throw new Error(`${method.toUpperCase()} ${path} → ${res.status()}\n${await res.text()}`)
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export type ContextFactory = (token?: string) => Promise<APIRequestContext>
|
||||
|
||||
export async function seedDemoData(
|
||||
api: APIRequestContext,
|
||||
newContext?: ContextFactory,
|
||||
): Promise<SeedResult> {
|
||||
// 1. Addons first — the Collections and Journey guards run ahead of auth, so
|
||||
// every later call to those modules 403s until these are flipped.
|
||||
for (const id of ['collections', 'journey', 'packing', 'budget', 'atlas', 'vacay', 'mcp', 'documents', 'collab']) {
|
||||
await call(api, 'put', `/api/admin/addons/${id}`, { enabled: true })
|
||||
}
|
||||
await call(api, 'put', '/api/admin/bag-tracking', { enabled: true }).catch(() => {})
|
||||
|
||||
// 1b. Units, pinned explicitly so the screenshots don't silently change meaning
|
||||
// when a default does. They match the current defaults (ba3733da made
|
||||
// celsius/metric/24h consistent across the store and the settings UI) —
|
||||
// stating them here keeps the captures reproducible either way.
|
||||
await call(api, 'post', '/api/settings/bulk', {
|
||||
settings: { temperature_unit: 'celsius', distance_unit: 'metric' },
|
||||
})
|
||||
|
||||
// 2. Extra members. Ignore 409 so a re-run against a warm DB still works.
|
||||
const memberIds: number[] = []
|
||||
for (const m of MEMBERS) {
|
||||
const res = await api.post('/api/admin/users', { data: m })
|
||||
if (res.ok()) {
|
||||
const { user } = (await res.json()) as { user: { id: number } }
|
||||
memberIds.push(user.id)
|
||||
} else if (res.status() !== 409) {
|
||||
throw new Error(`create user ${m.username} → ${res.status()}\n${await res.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The trip, in JPY.
|
||||
const { trip } = await call<{ trip: { id: number } }>(api, 'post', '/api/trips', TRIP)
|
||||
const tripId = trip.id
|
||||
|
||||
for (const m of MEMBERS) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/members`, { identifier: m.email }).catch(() => {})
|
||||
}
|
||||
|
||||
// 4. Days are auto-generated by trip creation — read them back for assignment.
|
||||
const days = await call<Array<{ id: number }> | { days: Array<{ id: number }> }>(
|
||||
api, 'get', `/api/trips/${tripId}/days`)
|
||||
const dayIds = (Array.isArray(days) ? days : days.days).map(d => d.id)
|
||||
|
||||
// 5. Places, then pin each onto its day.
|
||||
const placeIds: number[] = []
|
||||
for (const p of PLACES) {
|
||||
const { day, ...payload } = p
|
||||
const { place } = await call<{ place: { id: number } }>(
|
||||
api, 'post', `/api/trips/${tripId}/places`, payload)
|
||||
placeIds.push(place.id)
|
||||
const dayId = dayIds[day]
|
||||
if (dayId) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/days/${dayId}/assignments`,
|
||||
{ place_id: place.id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. A day note, so the itinerary shows more than places.
|
||||
if (dayIds[0]) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/days/${dayIds[0]}/notes`, {
|
||||
text: 'Pick up the JR Pass at the airport counter before taking the train in.',
|
||||
time: '08:15', icon: 'train',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 7. Costs. Split across everyone so the settle-up view has real balances.
|
||||
// NOTE: never send exchange_rate — the server freezes the FX rate itself,
|
||||
// and a hand-supplied one fights the settlement maths.
|
||||
const allMembers = [1, ...memberIds]
|
||||
for (const e of EXPENSES) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/budget`, {
|
||||
...e,
|
||||
payers: [{ user_id: 1, amount: e.total_price }],
|
||||
member_ids: allMembers,
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// A foreign-currency settle-up payment — the v3.4.0 feature worth showing.
|
||||
if (memberIds[0]) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/budget/settlements`, {
|
||||
from_user_id: memberIds[0], to_user_id: 1, amount: 120, currency: 'EUR',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 8. Packing — category is free text on the item, there is no category resource.
|
||||
for (const group of PACKING) {
|
||||
for (const name of group.items) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/packing`, {
|
||||
name, category: group.category, visibility: 'common',
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
for (const t of TODOS) {
|
||||
await call(api, 'post', `/api/trips/${tripId}/todo`, t).catch(() => {})
|
||||
}
|
||||
|
||||
// 9. A multi-leg flight. Coordinates are mandatory — endpoints without them
|
||||
// are silently dropped by the server, leaving a booking with no route.
|
||||
await call(api, 'post', `/api/trips/${tripId}/reservations`, {
|
||||
title: 'LH716 FRA → HND',
|
||||
type: 'flight',
|
||||
reservation_time: '2026-09-12T13:05:00',
|
||||
reservation_end_time: '2026-09-13T08:25:00',
|
||||
confirmation_number: 'X7K2QP',
|
||||
status: 'confirmed',
|
||||
location: 'Frankfurt Airport',
|
||||
metadata: { airline: 'Lufthansa', flight_number: 'LH716',
|
||||
departure_airport: 'FRA', arrival_airport: 'HND' },
|
||||
endpoints: [
|
||||
{ role: 'from', sequence: 0, name: 'Frankfurt Airport', code: 'FRA',
|
||||
lat: 50.0379, lng: 8.5622, timezone: 'Europe/Berlin',
|
||||
local_date: '2026-09-12', local_time: '13:05' },
|
||||
{ role: 'to', sequence: 1, name: 'Tokyo Haneda', code: 'HND',
|
||||
lat: 35.5494, lng: 139.7798, timezone: 'Asia/Tokyo',
|
||||
local_date: '2026-09-13', local_time: '08:25' },
|
||||
],
|
||||
}).catch(() => {})
|
||||
|
||||
// 10. A collection, populated from the trip's own places.
|
||||
let collectionId: number | undefined
|
||||
try {
|
||||
const created = await call<{ id: number } | { collection: { id: number } }>(
|
||||
api, 'post', '/api/addons/collections',
|
||||
{ name: 'Kyoto shortlist', description: 'Places we want to reach on the second week.',
|
||||
color: '#ef4444', icon: 'MapPin' })
|
||||
collectionId = 'id' in created ? created.id : created.collection.id
|
||||
for (const placeId of placeIds.slice(4)) {
|
||||
await call(api, 'post', '/api/addons/collections/places/from-trip', {
|
||||
collection_id: collectionId, source_trip_id: tripId, source_place_id: placeId, force: true,
|
||||
}).catch(() => {})
|
||||
}
|
||||
} catch { /* collections addon unavailable — screenshots for it will be skipped */ }
|
||||
|
||||
// 11. Journey. Entries are generated server-side from the trip, then filled in.
|
||||
let journeyId: number | undefined
|
||||
try {
|
||||
const j = await call<{ id: number } | { journey: { id: number } }>(
|
||||
api, 'post', '/api/journeys',
|
||||
{ title: 'Autumn in Japan', subtitle: 'Momiji season, Tokyo to Kyoto', trip_ids: [tripId] })
|
||||
journeyId = 'id' in j ? j.id : j.journey.id
|
||||
} catch { /* journey addon unavailable */ }
|
||||
|
||||
// 11b. Collab: chat, notes and polls.
|
||||
//
|
||||
// Chat is only convincing with more than one voice, and every collab
|
||||
// write is attributed to the acting user — so messages and votes are
|
||||
// posted as the members themselves, via their own bearer tokens, not as
|
||||
// the admin. A single-speaker chat log would misrepresent the feature.
|
||||
// Each member gets its OWN request context. Logging in through the shared
|
||||
// one would set the trek_session cookie on it, and extractToken()
|
||||
// (server/src/middleware/auth.ts:9) reads the cookie BEFORE the
|
||||
// Authorization header — so every later write, including the admin's,
|
||||
// would silently be attributed to whoever logged in last.
|
||||
const members: Record<string, APIRequestContext> = {}
|
||||
for (const m of MEMBERS) {
|
||||
if (!newContext) break
|
||||
const anon = await newContext()
|
||||
const res = await anon.post('/api/auth/login', { data: { email: m.email, password: m.password } })
|
||||
if (!res.ok()) { await anon.dispose(); continue }
|
||||
const { token } = (await res.json()) as { token?: string }
|
||||
await anon.dispose()
|
||||
if (token) members[m.username] = await newContext(token)
|
||||
}
|
||||
/** The member's own context, or the admin's as a visible fallback. */
|
||||
const as = (username: string): APIRequestContext => members[username] ?? api
|
||||
|
||||
const collab = `/api/trips/${tripId}/collab`
|
||||
|
||||
for (const n of [
|
||||
{ title: 'Rail passes', category: 'Transport', color: '#3b82f6',
|
||||
content: 'The 14-day JR Pass covers the Tokyo–Kyoto legs. Activate it at the airport counter on arrival, not before.' },
|
||||
{ title: 'Ryokan etiquette', category: 'Accommodation', color: '#ef4444',
|
||||
content: 'Shoes off at the entrance, yukata for dinner. Dinner is served at 18:30 sharp — being late is genuinely rude.' },
|
||||
{ title: 'Rainy-day alternatives', category: 'Ideas', color: '#22c55e',
|
||||
content: 'teamLab Planets, the Kyoto Railway Museum and Nishiki Market all work in bad weather.' },
|
||||
]) {
|
||||
await api.post(`${collab}/notes`, { data: n }).catch(() => {})
|
||||
}
|
||||
|
||||
const pollRes = await api.post(`${collab}/polls`, {
|
||||
data: {
|
||||
question: 'Which day should we keep free for Nara?',
|
||||
options: ['Wed, Sep 16', 'Thu, Sep 17', 'Sat, Sep 19'],
|
||||
multiple: false,
|
||||
},
|
||||
})
|
||||
if (pollRes.ok()) {
|
||||
const { poll } = (await pollRes.json()) as { poll: { id: number | string } }
|
||||
await api.post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
|
||||
await as('mira').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 1 } }).catch(() => {})
|
||||
await as('jonas').post(`${collab}/polls/${poll.id}/vote`, { data: { option_index: 2 } }).catch(() => {})
|
||||
}
|
||||
await api.post(`${collab}/polls`, {
|
||||
data: { question: 'Ryokan or city hotel in Hakone?', options: ['Ryokan with onsen', 'City hotel'], multiple: false },
|
||||
}).catch(() => {})
|
||||
|
||||
const conversation: Array<[string, string]> = [
|
||||
['admin', 'Flights are booked — we land at Haneda 08:25 on the 13th.'],
|
||||
['mira', 'Nice. Should we go straight to the hotel or drop bags and head out?'],
|
||||
['jonas', 'Drop bags. I want to be at Senso-ji before the crowds.'],
|
||||
['admin', "Agreed. I've put it on day 1 with a note to go before 08:00."],
|
||||
['mira', 'Booked the teamLab slot for the 13th, 14:00. Tickets are in the Files tab.'],
|
||||
['jonas', 'Do we need to reserve the ryokan dinner separately?'],
|
||||
['admin', "It's included — kaiseki, 18:30. Added it to the to-dos so we don't forget to confirm."],
|
||||
]
|
||||
for (const [who, text] of conversation) {
|
||||
const ctx = who === 'admin' ? api : as(who)
|
||||
await ctx.post(`${collab}/messages`, { data: { text } }).catch(() => {})
|
||||
}
|
||||
|
||||
for (const ctx of Object.values(members)) await ctx.dispose()
|
||||
|
||||
// 12. Plugins, installed from the community registry.
|
||||
//
|
||||
// Registry install is the ONLY path that produces a representative
|
||||
// screenshot. Dev-link and sideload both stamp the plugin card with a
|
||||
// badge ("Dev-Link" / "Sideloaded", AdminPluginsPanel.tsx:307,361) that no
|
||||
// ordinary install shows, and TREK_PLUGINS_DEV_LINK additionally reveals a
|
||||
// "Link a local plugin" row in the panel. Documenting either would show
|
||||
// readers a UI they will never have.
|
||||
//
|
||||
// Needs network. If the registry is unreachable the plugin screenshots are
|
||||
// skipped loudly rather than silently captured in a misleading state.
|
||||
for (const id of ['koffi', 'trip-doctor']) {
|
||||
const res = await api.post('/api/admin/plugins/install', { data: { id } })
|
||||
if (!res.ok()) {
|
||||
console.log(`PLUGIN INSTALL FAILED ${id} → ${res.status()} ${await res.text()}`)
|
||||
continue
|
||||
}
|
||||
await api.post(`/api/admin/plugins/${id}/activate`, { data: {} })
|
||||
}
|
||||
|
||||
return { tripId, memberIds, dayIds, placeIds, collectionId, journeyId }
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { test, clearNotices } from './shot'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Settings and Admin tabs.
|
||||
*
|
||||
* Both pages use the shared PageSidebar with client-side tab state (no URL
|
||||
* segment per tab), so each capture clicks its way in. Labels come from
|
||||
* shared/src/i18n/en — note "General" is the tab the wiki still calls
|
||||
* "Display", which is one of the corrections this screenshot run supports.
|
||||
*/
|
||||
|
||||
async function openSidebarTab(page: Page, label: string) {
|
||||
await page.getByRole('button', { name: label, exact: true }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
test.describe('user settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
// Filename kept as UsrSettings.png — the wiki already references it.
|
||||
test('general tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'General')
|
||||
await shot.page_('UsrSettings')
|
||||
})
|
||||
|
||||
test('appearance tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Appearance')
|
||||
await shot.page_('UsrSettingsAppearance')
|
||||
})
|
||||
|
||||
test('map tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Map')
|
||||
await shot.page_('UsrSettingsMap')
|
||||
})
|
||||
|
||||
test('notifications tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Notifications')
|
||||
await shot.page_('NotifSettings')
|
||||
})
|
||||
|
||||
test('offline tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Offline')
|
||||
await shot.page_('SettingsOffline')
|
||||
})
|
||||
|
||||
test('account tab', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Account')
|
||||
await shot.page_('SettingsAccount')
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('admin panel', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/admin')
|
||||
await clearNotices(page)
|
||||
})
|
||||
|
||||
test('users', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Users')
|
||||
await shot.page_('UsersAndInvites')
|
||||
})
|
||||
|
||||
test('user defaults', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'User Defaults')
|
||||
await shot.page_('AdminUserDefaults')
|
||||
})
|
||||
|
||||
test('personalization', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Personalization')
|
||||
await shot.page_('CategoryManager')
|
||||
})
|
||||
|
||||
test('addons', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Addons')
|
||||
await shot.page_('Addons-Overview')
|
||||
})
|
||||
|
||||
test('plugins', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Plugins')
|
||||
await shot.page_('AdminPlugins')
|
||||
})
|
||||
|
||||
test('github releases', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'GitHub')
|
||||
await shot.page_('GithubReleases')
|
||||
})
|
||||
|
||||
test('backup', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Backup')
|
||||
await shot.page_('Backup')
|
||||
})
|
||||
|
||||
test('audit log', async ({ page, shot }) => {
|
||||
await openSidebarTab(page, 'Audit')
|
||||
await shot.page_('Audit')
|
||||
})
|
||||
|
||||
test('admin panel overview', async ({ page, shot }) => {
|
||||
await shot.page_('AdminPanel')
|
||||
})
|
||||
})
|
||||
@@ -1,119 +0,0 @@
|
||||
import { test as base, expect, type Page, type Locator } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Shared plumbing for the documentation screenshot run (`npm run shots`).
|
||||
*
|
||||
* These are not assertions about behaviour — they drive the app to a known
|
||||
* state and capture it for the wiki. They live behind their own Playwright
|
||||
* project (`screenshots`, testMatch /\.shot\.ts/) so a normal `npm run e2e`
|
||||
* never pays for them.
|
||||
*
|
||||
* Output goes to a staging directory, NOT straight into wiki/assets/, so a
|
||||
* bad run can never clobber good artwork. Promote with `npm run shots:promote`.
|
||||
*/
|
||||
|
||||
// Playwright runs from the client workspace root, matching how
|
||||
// playwright.config.ts spells `storageState: 'e2e/.tmp/state.json'`.
|
||||
export const OUT_DIR = path.join(process.cwd(), 'e2e', '.tmp', 'shots')
|
||||
|
||||
/** Desktop capture size. 2x scale keeps text crisp; images are squeezed on promote. */
|
||||
export const VIEWPORT = { width: 1440, height: 900 }
|
||||
|
||||
export const test = base.extend<{ shot: Shot }>({
|
||||
// Overriding `page` (rather than doing this inside the `shot` fixture) is
|
||||
// deliberate: fixtures initialise lazily, so a route registered in `shot`
|
||||
// lands AFTER any beforeEach hook has already navigated — too late to
|
||||
// intercept the config request.
|
||||
page: async ({ page }, use) => {
|
||||
await page.setViewportSize(VIEWPORT)
|
||||
await hideDevOnlyUi(page)
|
||||
await use(page)
|
||||
},
|
||||
shot: async ({ page }, use) => {
|
||||
mkdirSync(OUT_DIR, { recursive: true })
|
||||
await use(new Shot(page))
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* The E2E backend runs with NODE_ENV=development, so /auth/app-config reports
|
||||
* `dev_mode: true` (authService.ts) and the admin sidebar grows a
|
||||
* "Dev: Notifications" tab that no real deployment ever shows.
|
||||
*
|
||||
* Rewriting the response is the surgical fix. Flipping the server to
|
||||
* NODE_ENV=production would also enable HSTS (globalMiddleware.ts), and an
|
||||
* HSTS header on localhost would upgrade the run to https and break it.
|
||||
*/
|
||||
async function hideDevOnlyUi(page: Page): Promise<void> {
|
||||
await page.route('**/api/auth/app-config', async route => {
|
||||
const res = await route.fetch()
|
||||
const body = await res.json()
|
||||
await route.fulfill({ response: res, json: { ...body, dev_mode: false } })
|
||||
})
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
export class Shot {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
/**
|
||||
* Capture the full viewport. `name` is the target filename in wiki/assets/
|
||||
* (without extension) so the mapping from screenshot to doc page is literal.
|
||||
*/
|
||||
async page_(name: string): Promise<void> {
|
||||
await this.settle()
|
||||
await this.page.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
/** Capture one element — preferred for dialogs, panels and cards. */
|
||||
async element(name: string, target: Locator): Promise<void> {
|
||||
await this.settle()
|
||||
await expect(target).toBeVisible()
|
||||
await target.screenshot({ path: path.join(OUT_DIR, `${name}.png`) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet the page before capturing: fonts loaded, images decoded, animations
|
||||
* finished, no pending network. Without this, screenshots catch skeleton
|
||||
* loaders and half-faded modals, which is exactly how the current wiki
|
||||
* assets ended up inconsistent.
|
||||
*/
|
||||
private async settle(): Promise<void> {
|
||||
// Bounded: TREK holds a WebSocket open at /ws, so the network never goes
|
||||
// fully idle and an unbounded wait would burn the whole test timeout.
|
||||
await this.page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => {})
|
||||
// Await, but return nothing — the resolved FontFaceSet is not serialisable.
|
||||
await this.page.evaluate(async () => { await document.fonts.ready })
|
||||
await this.page.evaluate(async () => {
|
||||
await Promise.all(
|
||||
Array.from(document.images)
|
||||
.filter(img => !img.complete)
|
||||
.map(img => new Promise(res => { img.onload = img.onerror = res })),
|
||||
)
|
||||
})
|
||||
// Let CSS transitions land (modal fade-in, sidebar slide).
|
||||
await this.page.waitForTimeout(400)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the first-run system notice. Copied in spirit from e2e/helpers.ts,
|
||||
* but tolerant: on a seeded DB the notice may already be cleared.
|
||||
*/
|
||||
export async function clearNotices(page: Page): Promise<void> {
|
||||
const next = page.getByRole('button', { name: /next/i })
|
||||
for (let i = 0; i < 6 && (await next.isVisible().catch(() => false)); i++) {
|
||||
if (!(await next.isEnabled().catch(() => false))) break
|
||||
await next.click().catch(() => {})
|
||||
}
|
||||
for (const label of ['Dismiss', 'OK']) {
|
||||
const btn = page.getByRole('button', { name: label, exact: true })
|
||||
for (let i = 0; i < 4 && (await btn.isVisible().catch(() => false)); i++) {
|
||||
await btn.click().catch(() => {})
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Open a trip into the planner: create a trip, open it from the dashboard, and
|
||||
// confirm the trip planner (TripPlannerPage — the app's largest page) actually
|
||||
@@ -7,17 +6,12 @@ import { dismissSystemNotices } from './helpers'
|
||||
test('open a trip and land in the planner with a map', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
|
||||
// The release notice greets a freshly seeded user and its backdrop eats the click below.
|
||||
await dismissSystemNotices(page)
|
||||
|
||||
// Create a trip to open.
|
||||
await page.locator('.add-trip-card').click()
|
||||
const modal = page.locator('.trek-modal-backdrop')
|
||||
const modal = page.locator('.modal-backdrop')
|
||||
await expect(modal).toBeVisible()
|
||||
// Target Title by placeholder: the cover-image search inputs sit above it, so
|
||||
// input[type=text].first() is the photo search box, not the field we want.
|
||||
const title = `E2E Planner ${Date.now()}`
|
||||
await modal.getByPlaceholder('e.g. Summer in Japan').fill(title)
|
||||
await modal.locator('input[type="text"]').first().fill(title)
|
||||
await modal.getByRole('button', { name: 'Create New Trip' }).click()
|
||||
|
||||
// Open it from the dashboard.
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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)
|
||||
})
|
||||
@@ -75,25 +75,4 @@ 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 <svg>. 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 <svg>.',
|
||||
}],
|
||||
}],
|
||||
'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 <svg>.',
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+10
-6
@@ -5,10 +5,6 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>TREK</title>
|
||||
|
||||
<!-- Pre-paint appearance (FOUC fix). External classic script so it runs
|
||||
before first paint AND complies with the prod CSP (script-src 'self'). -->
|
||||
<script src="/theme-boot.js"></script>
|
||||
|
||||
<!-- PWA / iOS -->
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -17,9 +13,17 @@
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||
|
||||
<!-- Fonts are bundled from @fontsource in src/main.tsx, see the note there. -->
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||
|
||||
<!-- Leaflet -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+14
-34
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.1",
|
||||
"version": "3.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -18,56 +17,38 @@
|
||||
"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",
|
||||
"shots": "playwright test --project=screenshots",
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"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",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
"dexie": "^4.4.2",
|
||||
"drag-drop-touch": "^1.3.1",
|
||||
"heic-to": "^1.4.2",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.344.0",
|
||||
"mapbox-gl": "^3.22.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "^18.0.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^14.4.1",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-leaflet-cluster": "^4.1.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.18.2",
|
||||
"react-router-dom": "^6.22.2",
|
||||
"react-window": "^2.2.7",
|
||||
"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": "^5.0.14"
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
@@ -77,11 +58,11 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -95,13 +76,12 @@
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sharp": "^0.35.4",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "8.1.0",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.11"
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,28 +35,6 @@ export default defineConfig({
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
// Documentation screenshots (`npm run shots`). Excluded from the normal e2e
|
||||
// run by its own testMatch — these capture artwork for wiki/assets/, they
|
||||
// assert nothing. 2x scale keeps text crisp at the sizes the wiki renders.
|
||||
// Populates the demo trip the screenshots are taken of. Separate project so
|
||||
// it runs exactly once, between auth and capture.
|
||||
{
|
||||
name: 'seed',
|
||||
testMatch: /seed\.setup\.ts/,
|
||||
use: { ...devices['Desktop Chrome'], storageState: 'e2e/.tmp/state.json' },
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
name: 'screenshots',
|
||||
testMatch: /\.shot\.ts/,
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.tmp/state.json',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
deviceScaleFactor: 2,
|
||||
},
|
||||
dependencies: ['seed'],
|
||||
},
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="#3c83f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="lucide lucide-tower-control-icon lucide-tower-control">
|
||||
<path d="M18.2 12.27 20 6H4l1.8 6.27a1 1 0 0 0 .95.73h10.5a1 1 0 0 0 .96-.73Z" />
|
||||
<path d="M8 13v9" />
|
||||
<path d="M16 22v-9" />
|
||||
<path d="m9 6 1 7" />
|
||||
<path d="m15 6-1 7" />
|
||||
<path d="M12 6V2" />
|
||||
<path d="M13 2h-2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 495 B |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.3 KiB |
@@ -1,58 +0,0 @@
|
||||
/*
|
||||
* Pre-paint appearance boot — kills the flash of default/wrong theme (FOUC).
|
||||
*
|
||||
* Loaded as an external, render-blocking CLASSIC script in <head> (NOT a module)
|
||||
* so it runs before first paint AND complies with the production CSP
|
||||
* (script-src 'self'; inline scripts are blocked). It reads the compact snapshot
|
||||
* written by client/src/theme/applyAppearance.ts and applies it verbatim. Keep
|
||||
* this in sync with that module's snapshot shape + apply logic.
|
||||
*
|
||||
* It must never throw — any failure silently falls back to the default look.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
var raw = localStorage.getItem('trek_appearance');
|
||||
if (!raw) return;
|
||||
var s = JSON.parse(raw);
|
||||
if (!s || s.v !== 1) return;
|
||||
|
||||
var root = document.documentElement;
|
||||
var path = location.pathname;
|
||||
var isShared = path.indexOf('/shared/') === 0 || path.indexOf('/public/') === 0;
|
||||
|
||||
var dark;
|
||||
if (isShared) dark = false;
|
||||
else if (s.darkMode === 'auto') dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
else dark = s.darkMode === true || s.darkMode === 'dark';
|
||||
root.classList.toggle('dark', dark);
|
||||
|
||||
var scheme = isShared ? 'default' : s.scheme;
|
||||
if (scheme && scheme !== 'default') root.setAttribute('data-scheme', scheme);
|
||||
if (!isShared && s.noTransparency) root.setAttribute('data-no-transparency', '');
|
||||
if (s.density === 'compact') root.setAttribute('data-density', 'compact');
|
||||
if (s.reduceMotion) root.setAttribute('data-reduce-motion', '');
|
||||
|
||||
if (!isShared && scheme === 'custom' && s.accent) {
|
||||
root.style.setProperty('--accent-custom-light', s.accent.light);
|
||||
root.style.setProperty('--accent-custom-dark', s.accent.dark);
|
||||
if (s.accentText) {
|
||||
root.style.setProperty('--accent-custom-text-light', s.accentText.light);
|
||||
root.style.setProperty('--accent-custom-text-dark', s.accentText.dark);
|
||||
}
|
||||
}
|
||||
|
||||
var ts = s.typeScale || {};
|
||||
var fs = typeof s.fontScale === 'number' ? s.fontScale : 1;
|
||||
setScale('--fs-scale-title', fs * (ts.title || 1));
|
||||
setScale('--fs-scale-subtitle', fs * (ts.subtitle || 1));
|
||||
setScale('--fs-scale-body', fs * (ts.body || 1));
|
||||
setScale('--fs-scale-caption', fs * (ts.caption || 1));
|
||||
if (fs !== 1) root.style.fontSize = fs * 100 + '%';
|
||||
|
||||
function setScale(name, v) {
|
||||
if (typeof v === 'number' && v !== 1) root.style.setProperty(name, String(v));
|
||||
}
|
||||
} catch (e) {
|
||||
/* never block boot */
|
||||
}
|
||||
})();
|
||||
@@ -1,39 +0,0 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
|
||||
* with importScripts. mapbox-gl-rtl-text ships exactly that as
|
||||
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
|
||||
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
|
||||
* and stepping across to dist keeps this working wherever npm hoists the package.
|
||||
*
|
||||
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
|
||||
* asset, and the test run needs it because a lazily imported engine module reaches
|
||||
* the same specifier.
|
||||
*
|
||||
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
|
||||
* which a string alias will not match. It is left unanchored on purpose so the
|
||||
* query survives the rewrite and Vite still emits an asset URL.
|
||||
*/
|
||||
export const rtlTextAlias = {
|
||||
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
|
||||
/**
|
||||
* Same shape for Plyr's icon sprite. The package publishes its JS and CSS entries
|
||||
* and nothing else, so `plyr/dist/plyr.svg` is refused by the resolver although
|
||||
* the file is right there. The player has to point at a copy on this origin
|
||||
* (#2341): its default is the sprite on cdn.plyr.io, which the CSP refuses.
|
||||
*/
|
||||
export const plyrSpriteAlias = {
|
||||
find: /^plyr\/dist\/plyr\.svg/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('plyr')),
|
||||
'plyr.svg',
|
||||
),
|
||||
};
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/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<string, CountryShape> = {
|
||||
${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)}`)
|
||||
@@ -1,47 +0,0 @@
|
||||
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)
|
||||
@@ -2,12 +2,9 @@
|
||||
// src/pages/PATTERN.md). A *Page.tsx default-export component should wire a
|
||||
// co-located use<Page>() hook into JSX — it must not own state/effects itself.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
// 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'
|
||||
@@ -16,38 +13,23 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
const pagesDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'pages')
|
||||
const BANNED = ['useState', 'useReducer', 'useEffect', 'useLayoutEffect', 'useMemo', 'useCallback', 'useRef']
|
||||
// `[<(]` 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 bannedRe = new RegExp(`\\b(${BANNED.join('|')})\\s*\\(`)
|
||||
|
||||
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 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
|
||||
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])) {
|
||||
violations.push(`${file}:${i + 1} ${lines[i].trim()}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,25 +26,4 @@ for (const { name, size } of sizes) {
|
||||
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||
}
|
||||
|
||||
// Maskable variants: Android lays a maskable icon edge to edge under the round
|
||||
// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80%
|
||||
// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and
|
||||
// recenter it, keeping the brand gradient as the full background.
|
||||
const maskableSvg = svgBuffer
|
||||
.toString()
|
||||
.replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)');
|
||||
|
||||
const maskableSizes = [
|
||||
{ name: 'icon-maskable-192x192.png', size: 192 },
|
||||
{ name: 'icon-maskable-512x512.png', size: 512 },
|
||||
];
|
||||
|
||||
for (const { name, size } of maskableSizes) {
|
||||
await sharp(Buffer.from(maskableSvg), { density: 300 })
|
||||
.resize(size, size)
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(join(iconsDir, name));
|
||||
console.log(` \u2713 ${name} (${size}x${size}, maskable)`);
|
||||
}
|
||||
|
||||
console.log('PWA icons generated.');
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theme:lint — guards the appearance token system.
|
||||
*
|
||||
* Flags styling that bypasses the design tokens and therefore won't follow a
|
||||
* user's chosen scheme / transparency / text-size:
|
||||
* - inline color literals (color: '#111', background: 'rgba(...)', boxShadow: '...rgba...')
|
||||
* - inline numeric fontSize (fontSize: 13)
|
||||
* - arbitrary-value Tailwind color classes (bg-[#..], text-[rgba(..)])
|
||||
*
|
||||
* ALLOWED (never flagged): var(--token) inline styles, bg-[var(--..)] classes,
|
||||
* and genuinely dynamic values (data-driven colors, computed sizes/positions).
|
||||
*
|
||||
* Mirrors the i18n:parity gate. Default mode reports a baseline and exits 0;
|
||||
* `--strict` exits non-zero when any violations remain (for once the backlog is
|
||||
* burned down, or wired to changed files only). Add `theme-lint-disable` in a
|
||||
* line comment to suppress an intentional exception (map/PDF/brand colors).
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
let SRC = new URL('../src', import.meta.url).pathname;
|
||||
if (process.platform === 'win32' && SRC.startsWith('/')) SRC = SRC.slice(1);
|
||||
|
||||
// Surfaces where CSS variables genuinely cannot reach (injected map HTML, WebGL
|
||||
// paint, standalone PDF documents) — colors there must stay literal.
|
||||
const EXEMPT = [
|
||||
/Mapbox/i, /placePopup/i, /marker/i, /popup/i, /TripPDF/, /JourneyBookPDF/,
|
||||
/MapViewGL/, /MapView\./, /JourneyMapGL/, /reservationsMapbox/, /useAtlas/,
|
||||
/ReservationOverlay/, /\.test\./, /\.spec\./,
|
||||
];
|
||||
|
||||
const ARB_CLASS = /\b(?:bg|text|border|ring|fill|stroke|from|via|to|shadow|outline|decoration|divide|caret)-\[\s*(?:#|rgba?\(|hsla?\(|oklch\()/;
|
||||
const INLINE_COLOR = /(?:color|background|backgroundColor|borderColor|border|borderTop|borderBottom|borderLeft|borderRight|boxShadow|fill|stroke|outline|textDecorationColor)\s*:\s*['"`]?\s*(?:#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|oklch\()/;
|
||||
const INLINE_FONTSIZE = /fontSize\s*:\s*['"`]?\d/;
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) walk(p, files);
|
||||
else if (/\.(ts|tsx)$/.test(name)) files.push(p);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
const strict = process.argv.includes('--strict');
|
||||
const offenders = [];
|
||||
let total = 0;
|
||||
|
||||
for (const f of walk(SRC)) {
|
||||
if (EXEMPT.some((re) => re.test(f))) continue;
|
||||
let count = 0;
|
||||
for (const line of readFileSync(f, 'utf8').split('\n')) {
|
||||
if (line.includes('theme-lint-disable')) continue;
|
||||
if (ARB_CLASS.test(line) || INLINE_COLOR.test(line) || INLINE_FONTSIZE.test(line)) count++;
|
||||
}
|
||||
if (count) {
|
||||
offenders.push([relative(SRC, f).replace(/\\/g, '/'), count]);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
|
||||
offenders.sort((a, b) => b[1] - a[1]);
|
||||
console.log(`theme:lint — ${total} hardcoded-style hits across ${offenders.length} files (map/PDF excluded).`);
|
||||
for (const [f, c] of offenders.slice(0, 20)) console.log(` ${String(c).padStart(4)} ${f}`);
|
||||
if (offenders.length > 20) console.log(` … and ${offenders.length - 20} more files.`);
|
||||
console.log('\nNew/changed code must use tokens (bg-surface / text-content / bg-accent / var(--..)) and the');
|
||||
console.log('text-title/subtitle/body/caption tiers — never inline #hex, never bg-[#..]. See src/theme/README.md.');
|
||||
|
||||
if (strict && total > 0) {
|
||||
console.error(`\n✖ theme:lint:strict — ${total} violations remain.`);
|
||||
process.exit(1);
|
||||
}
|
||||
+4
-337
@@ -1,15 +1,13 @@
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, act } from '@testing-library/react'
|
||||
import { MemoryRouter, useLocation } from 'react-router'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
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, buildTrip } from '../tests/helpers/factories'
|
||||
import { offlineDb } from './db/offlineDb'
|
||||
import { SETTINGS_WAIT_MS } from './utils/startDestination'
|
||||
import { buildUser, buildSettings } from '../tests/helpers/factories'
|
||||
import App from './App'
|
||||
|
||||
// ── Mock page components ───────────────────────────────────────────────────────
|
||||
@@ -31,19 +29,10 @@ 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 <span data-testid="loc">{loc.pathname + loc.search}</span>
|
||||
}
|
||||
|
||||
const currentPath = () => screen.getByTestId('loc').textContent
|
||||
|
||||
function renderApp(initialPath = '/') {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[initialPath]}>
|
||||
<App />
|
||||
<LocationProbe />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
@@ -92,105 +81,6 @@ 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', () => {
|
||||
@@ -200,22 +90,6 @@ 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')
|
||||
@@ -295,8 +169,6 @@ 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')
|
||||
@@ -306,7 +178,7 @@ describe('Public routes', () => {
|
||||
it('FE-COMP-APP-013: /shared/:token is accessible without authentication', async () => {
|
||||
seedAuth({ isAuthenticated: false })
|
||||
renderApp('/shared/sometoken')
|
||||
expect(await screen.findByText('SharedTrip')).toBeInTheDocument()
|
||||
expect(screen.getByText('SharedTrip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-014: unknown routes redirect to / which then redirects to /login', async () => {
|
||||
@@ -316,57 +188,6 @@ 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', () => {
|
||||
@@ -412,30 +233,6 @@ 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() })
|
||||
@@ -522,134 +319,4 @@ describe('Version cache-busting', () => {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
|
||||
// The old handler wiped every Cache Storage bucket and every registration
|
||||
// here, which left the device with no app shell until a fresh ~22 MB
|
||||
// precache finished: anyone who closed the app or lost signal inside that
|
||||
// window could no longer start the PWA offline at all, and it also threw
|
||||
// away the map tiles and documents the user had downloaded on purpose.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const cachesDelete = vi.fn().mockResolvedValue(true)
|
||||
const unregister = vi.fn().mockResolvedValue(true)
|
||||
const update = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
// It asks the worker to fetch the new build; Workbox swaps the precache
|
||||
// atomically once that install completes.
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
expect(cachesDelete).not.toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
|
||||
// The value arrives over the wire and is written to this device's storage,
|
||||
// so it is validated rather than trusted. An unusable one must also not be
|
||||
// compared against the stored marker, or every launch would see a mismatch
|
||||
// and try to update.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
|
||||
// The marker used to be written after the purge and outside its try, so a
|
||||
// throwing step left it unwritten and the whole destructive path repeated on
|
||||
// every launch, for good.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
})
|
||||
})
|
||||
|
||||
// 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()
|
||||
})
|
||||
})
|
||||
|
||||
+154
-440
@@ -1,79 +1,35 @@
|
||||
import React, { useEffect, useState, useRef, ReactNode, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router'
|
||||
import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
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'
|
||||
// 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 AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
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 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 BottomNav from './components/Layout/BottomNav'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi, isAuthPublicPath } 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 { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
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
|
||||
@@ -86,11 +42,9 @@ 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 (
|
||||
@@ -104,10 +58,6 @@ 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 <Navigate to="/login" replace state={{ noRedirect: true }} />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
@@ -129,132 +79,18 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<MobileShell isPhone={isPhone}>
|
||||
<ErrorBoundary
|
||||
key={location.pathname}
|
||||
boundaryId="route"
|
||||
level="route"
|
||||
variant={isPhone ? 'mobile' : 'desktop'}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</MobileShell>
|
||||
<div className="flex flex-col h-screen md:block md:h-auto">
|
||||
<div className="flex-1 overflow-y-auto md:overflow-visible">{children}</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <Navigate to={START_DESTINATION_ROUTE} replace />
|
||||
}
|
||||
return (
|
||||
<ErrorBoundary key={location.pathname} boundaryId="public-route" level="route">
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? <Phone /> : <Desktop />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(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)
|
||||
|
||||
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)) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
|
||||
@@ -262,32 +98,13 @@ function RootRedirect() {
|
||||
)
|
||||
}
|
||||
|
||||
return <Navigate to={isAuthenticated ? (target ?? '/dashboard') : '/login'} replace />
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 <MRouteFallback />
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface">
|
||||
<div className="w-10 h-10 border-4 border-edge border-t-content rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)
|
||||
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname.startsWith('/shared/') && !location.pathname.startsWith('/public/') && !location.pathname.startsWith('/login')) {
|
||||
@@ -301,80 +118,50 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; 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; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
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<string, PermissionLevel> }) => {
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease)
|
||||
if (config?.version) setAppVersion(config.version)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key)
|
||||
if (config?.timezone) setServerTimezone(config.timezone)
|
||||
if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa)
|
||||
if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled)
|
||||
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?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so only a value made of
|
||||
// the characters a tag may contain is taken, as the match itself. Anything
|
||||
// else is ignored, which also keeps a malformed value from being compared
|
||||
// against the stored marker and starting an update on every launch.
|
||||
const releaseTag = /^[\w.+-]{1,64}$/.exec(typeof config?.version === 'string' ? config.version : '')
|
||||
if (releaseTag) {
|
||||
const version = releaseTag[0]
|
||||
if (config?.version) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
// mode, blocked site data, quota) left the caches deleted, the marker
|
||||
// unwritten and the reload unreached, and the purge then repeated on
|
||||
// every single launch, permanently (#2228).
|
||||
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
|
||||
if (storedVersion && storedVersion !== version) {
|
||||
// A newer build is deployed. Ask the service worker to fetch it and
|
||||
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
|
||||
// installs the new precache and only then drops the outdated one, so
|
||||
// there is never a moment without an app shell.
|
||||
//
|
||||
// This used to delete EVERY Cache Storage bucket and unregister EVERY
|
||||
// worker instead. That left the device with no shell and no worker
|
||||
// until a fresh ~22 MB precache finished (minutes on mobile data),
|
||||
// and anyone who closed the app or lost signal in that window was
|
||||
// left with a PWA that could no longer start offline at all. It also
|
||||
// threw away the map tiles and file blobs the user had deliberately
|
||||
// downloaded for offline use (#2228).
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
try {
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
|
||||
await reg.update()
|
||||
return
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
}
|
||||
} catch { /* fall through to a plain reload */ }
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
// Last, so the version block above keeps its lines: the taint analyser
|
||||
// re-raises its browser-storage finding for every line that moves under it.
|
||||
if (config?.places_provider) setPlacesProvider(config.places_provider)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const { settings } = useSettingsStore()
|
||||
|
||||
useInAppNotificationListener()
|
||||
useRoadtripPreferencesSync()
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSettings()
|
||||
loadAddons()
|
||||
loadPlugins()
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
@@ -387,206 +174,133 @@ export default function App() {
|
||||
const isSharedPage = location.pathname.startsWith('/shared/')
|
||||
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
applyAppearance({
|
||||
darkMode: settings.dark_mode,
|
||||
appearance: settings.appearance,
|
||||
isSharedPage,
|
||||
})
|
||||
run()
|
||||
// Re-resolve on OS theme change while in auto mode.
|
||||
if (!isSharedPage && settings.dark_mode === 'auto') {
|
||||
// Shared page always forces light mode
|
||||
if (isSharedPage) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', '#ffffff')
|
||||
return
|
||||
}
|
||||
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
|
||||
}
|
||||
|
||||
if (mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handler = () => run()
|
||||
applyDark(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
// No session on these, so authenticated-only widgets (system notices,
|
||||
// background tasks, save-to-collection) have nothing to do and would only fire
|
||||
// a doomed authenticated request on mount.
|
||||
//
|
||||
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
|
||||
// drifted, this one naming only /public/journey/ while the response
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> 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. */}
|
||||
<Suspense fallback={<RouteFallback />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/shared/:token" element={<PublicRoute><SharedTripPage /></PublicRoute>} />
|
||||
<Route path="/public/journey/:token" element={<PublicRoute><JourneyPublicPage /></PublicRoute>} />
|
||||
<Route path="/register" element={<PublicRoute redirectAuthed><LoginPage /></PublicRoute>} />
|
||||
<Route path="/forgot-password" element={<PublicRoute><ForgotPasswordPage /></PublicRoute>} />
|
||||
<Route path="/reset-password" element={<PublicRoute><ResetPasswordPage /></PublicRoute>} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/consent" element={<PublicRoute><OAuthAuthorizePage /></PublicRoute>} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MDashboardScreen} desktop={DashboardPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Trip invite link (#1143) — behind ProtectedRoute so an anonymous
|
||||
visitor is redirected to /login (never registration) and returns here. */}
|
||||
<Route
|
||||
path="/join/:token"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<JoinTripPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/help/:slug"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<HelpPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MTripScreen} desktop={TripPlannerPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id/files"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FilesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminRequired>
|
||||
<ViewportRoute phone={MAdminScreen} desktop={AdminPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MSettingsScreen} desktop={SettingsPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* 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 => (
|
||||
<Route
|
||||
key={r.path}
|
||||
path={r.path}
|
||||
element={<ProtectedRoute adminRequired={r.adminOnly}>{r.element}</ProtectedRoute>}
|
||||
/>
|
||||
))}
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MVacayScreen} desktop={VacayPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/atlas"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MAtlasScreen} desktop={AtlasPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<ViewportRoute phone={MJourneyScreen} desktop={JourneyPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
{/* 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. */}
|
||||
<Route path="studio" element={<JourneyStudioPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="/collections"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<ViewportRoute phone={MCollectionsScreen} desktop={CollectionsPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<ViewportRoute phone={MNotificationsScreen} desktop={InAppNotificationsPage} />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||
<Route path="/public/journey/:token" element={<JourneyPublicPage />} />
|
||||
<Route path="/register" element={<LoginPage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||
{/* OAuth 2.1 consent page — intentionally outside ProtectedRoute */}
|
||||
<Route path="/oauth/consent" element={<OAuthAuthorizePage />} />
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<DashboardPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<TripPlannerPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/trips/:id/files"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FilesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminRequired>
|
||||
<AdminPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/vacay"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<VacayPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/atlas"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AtlasPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/journey/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="journey">
|
||||
<JourneyDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notifications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<InAppNotificationsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</TranslationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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: () => <div>m-dashboard</div> }))
|
||||
vi.mock('./mobile/screens/trip/MTripShell', () => ({ default: () => <div>m-trip</div> }))
|
||||
vi.mock('./mobile/screens/admin/MAdmin', () => ({ default: () => <div>m-admin</div> }))
|
||||
vi.mock('./mobile/screens/settings/MSettings', () => ({ default: () => <div>m-settings</div> }))
|
||||
vi.mock('./mobile/screens/vacay/MVacay', () => ({ default: () => <div>m-vacay</div> }))
|
||||
vi.mock('./mobile/screens/atlas/MAtlas', () => ({ default: () => <div>m-atlas</div> }))
|
||||
vi.mock('./mobile/screens/journey/MJourney', () => ({ default: () => <div>m-journey</div> }))
|
||||
vi.mock('./mobile/screens/journey/MJourneyDetail', () => ({ default: () => <div>m-journey-detail</div> }))
|
||||
vi.mock('./mobile/screens/collections/MCollections', () => ({ default: () => <div>m-collections</div> }))
|
||||
vi.mock('./mobile/screens/notifications/MNotifications', () => ({ default: () => <div>m-notifications</div> }))
|
||||
|
||||
vi.mock('./pages/DashboardPage', () => ({ default: () => <div>d-dashboard</div> }))
|
||||
vi.mock('./pages/TripPlannerPage', () => ({ default: () => <div>d-trip</div> }))
|
||||
vi.mock('./pages/AdminPage', () => ({ default: () => <div>d-admin</div> }))
|
||||
vi.mock('./pages/SettingsPage', () => ({ default: () => <div>d-settings</div> }))
|
||||
vi.mock('./pages/VacayPage', () => ({ default: () => <div>d-vacay</div> }))
|
||||
vi.mock('./pages/AtlasPage', () => ({ default: () => <div>d-atlas</div> }))
|
||||
vi.mock('./pages/JourneyPage', () => ({ default: () => <div>d-journey</div> }))
|
||||
vi.mock('./pages/JourneyDetailPage', () => ({ default: () => <div>d-journey-detail</div> }))
|
||||
vi.mock('./pages/CollectionsPage', () => ({ default: () => <div>d-collections</div> }))
|
||||
vi.mock('./pages/InAppNotificationsPage.tsx', () => ({ default: () => <div>d-notifications</div> }))
|
||||
|
||||
// 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(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,7 +0,0 @@
|
||||
import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) {
|
||||
const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body)
|
||||
return assignmentSchema.parse(saved.data.assignment)
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
// 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<typeof vi.fn<() => 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<unknown>): Promise<AxiosError> {
|
||||
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<string>()
|
||||
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<boolean>) {
|
||||
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 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
|
||||
probeNow.mockResolvedValue('proxy-wall')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('<html>login</html>', { 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-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
|
||||
// text/html is not proof of a proxy: several of TREK's own routes answer
|
||||
// res.status(401).send('Authentication required'), which Express labels
|
||||
// text/html. Tearing the worker down for one of those costs the user
|
||||
// offline mode for a wall that is not there, so confirm reachability first.
|
||||
probeNow.mockResolvedValue('online')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
|
||||
expect(probeNow).toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
|
||||
})
|
||||
|
||||
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('<html>login</html>', { 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('<html>login</html>', { 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-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,948 +0,0 @@
|
||||
// FE-APISURF-001 to FE-APISURF-057
|
||||
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,
|
||||
docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS,
|
||||
} 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<unknown>; e: string }
|
||||
|
||||
/** Runs every call in isolation and checks the verb + path it produced. */
|
||||
async function assertCalls(calls: Call[]): Promise<void> {
|
||||
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<unknown>): Promise<Recorded> {
|
||||
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: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' },
|
||||
{ n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' },
|
||||
{ n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' },
|
||||
{ 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')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ 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' })
|
||||
})
|
||||
|
||||
it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => {
|
||||
const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway'))
|
||||
expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes')
|
||||
expect(rec.body).toEqual({ name: 'Norway' })
|
||||
})
|
||||
})
|
||||
|
||||
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<string, string> }).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<string, string>
|
||||
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(['<gpx/>'], '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')
|
||||
})
|
||||
|
||||
it('FE-APISURF-053: every channel test outlives the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
|
||||
// The server budgets up to 20s for an SMTP dial and 10s for a webhook or
|
||||
// ntfy ping; aborting at 8s threw away the reason and left the admin with a
|
||||
// bare "failed" (#2196).
|
||||
await notificationsApi.testSmtp('a@b.c')
|
||||
await notificationsApi.testWebhook('https://hook')
|
||||
await notificationsApi.testNtfy({ topic: 't' })
|
||||
await notificationsApi.testChannel('plugin/ch')
|
||||
|
||||
expect(post.mock.calls).toHaveLength(4)
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: 40000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
|
||||
const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
|
||||
|
||||
// The server allows each request to a store 15 s, and a run waits for the
|
||||
// whole listing and every transfer; cut off at 8 s the browser reported a
|
||||
// failure while the server carried on and finished.
|
||||
await docsyncApi.testConnection(1, { providerId: 'paperless' })
|
||||
await docsyncApi.createScope(1, 5, 'Norway')
|
||||
await docsyncApi.createLink(1, { connectionId: 5 })
|
||||
await docsyncApi.listScopes(1, 5)
|
||||
await docsyncApi.deleteLink(1, 9)
|
||||
|
||||
expect(post.mock.calls.map(c => c[0])).toEqual([
|
||||
'/trips/1/docsync/connections/test',
|
||||
'/trips/1/docsync/connections/5/scopes',
|
||||
'/trips/1/docsync/links',
|
||||
])
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
}
|
||||
expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes')
|
||||
expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9')
|
||||
expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS })
|
||||
expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000)
|
||||
|
||||
// A run, and a conflict choice that runs the binding afterwards.
|
||||
post.mockClear()
|
||||
await docsyncApi.syncNow(1, 9)
|
||||
await docsyncApi.resolve(1, 4, 'trek')
|
||||
|
||||
expect(post.mock.calls.map(c => c[0])).toEqual([
|
||||
'/trips/1/docsync/links/9/sync',
|
||||
'/trips/1/docsync/items/4/resolve',
|
||||
])
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS })
|
||||
}
|
||||
expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS)
|
||||
})
|
||||
})
|
||||
+84
-954
File diff suppressed because it is too large
Load Diff
@@ -1,364 +0,0 @@
|
||||
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034
|
||||
//
|
||||
// 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<T extends JsonBodyType>(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')
|
||||
})
|
||||
|
||||
// Both handlers answer with the BARE collection, the way the controller does —
|
||||
// stubbing a { collection } envelope here is what let the client keep reading
|
||||
// `.collection` off a response that never had one.
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', 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.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).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)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => {
|
||||
server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })))
|
||||
|
||||
const res = await collectionsApi.exportGpx(1)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1/export/gpx`)
|
||||
expect(res).toEqual({ name: 'Tokyo', gpx: '<gpx/>', waypoints: 0, omitted: 2 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => {
|
||||
const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] }
|
||||
server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 })))
|
||||
|
||||
const res = await collectionsApi.readGpx({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
|
||||
|
||||
expect(requestBody).toEqual({ gpx: '<gpx/>', file_name: 'tokyo.gpx' })
|
||||
expect(res).toEqual({ file, skipped: 0, track_points: 12 })
|
||||
})
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
import apiClient, { postMultipart } from './client'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import type {
|
||||
CollectionListResponse,
|
||||
CollectionDetailResponse,
|
||||
CollectionSaveResult,
|
||||
CollectionMembership,
|
||||
CollectionCreateRequest,
|
||||
CollectionUpdateRequest,
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionImportablesResponse,
|
||||
CollectionFile,
|
||||
CollectionImportRequest,
|
||||
CollectionImportIntoRequest,
|
||||
CollectionImportResult,
|
||||
CollectionGpxExport,
|
||||
CollectionGpxReadRequest,
|
||||
CollectionGpxReadResult,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
CollectionRole,
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
CollectionSetStatusManyResponse,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
CollectionLabelCreateRequest,
|
||||
CollectionLabelUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
|
||||
const ax = apiClient
|
||||
const base = '/addons/collections'
|
||||
|
||||
/** Query for the library-wide "is this place already saved?" lookup. */
|
||||
export interface MembershipQuery {
|
||||
google_place_id?: string
|
||||
google_ftid?: string
|
||||
name?: string
|
||||
lat?: number
|
||||
lng?: number
|
||||
}
|
||||
|
||||
export interface CopyToTripResult {
|
||||
copied: number
|
||||
skipped: { id: number; name: string }[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Axios calls for the Collections addon (/api/addons/collections). Mirrors the
|
||||
* vacayStore api shape — each method returns the unwrapped response body and
|
||||
* uses `satisfies` on the request payloads so the shared Zod request types stay
|
||||
* the single source of truth.
|
||||
*/
|
||||
export const collectionsApi = {
|
||||
list: (): Promise<CollectionListResponse> =>
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
// Export / import as a file (#2198). The export body IS the file; the import
|
||||
// sends the parsed file back through the same contract.
|
||||
exportFile: (id: number): Promise<CollectionFile> =>
|
||||
ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data),
|
||||
importFile: (body: CollectionImportRequest): Promise<CollectionImportResult> =>
|
||||
ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data),
|
||||
/** The same file into a list that already exists: it only ever adds to it. */
|
||||
importFileInto: (id: number, body: CollectionImportIntoRequest): Promise<CollectionImportResult> =>
|
||||
ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data),
|
||||
// The same list as GPX (#2301). A GPX is read by the server into a list file,
|
||||
// which then goes through importFile above like any other.
|
||||
exportGpx: (id: number): Promise<CollectionGpxExport> =>
|
||||
ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data),
|
||||
readGpx: (body: CollectionGpxReadRequest): Promise<CollectionGpxReadResult> =>
|
||||
ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data),
|
||||
// Both answer with the bare collection, not a { collection } envelope — the
|
||||
// controller returns the service's `Collection` straight through, and the e2e
|
||||
// suite pins that shape. Declaring the envelope made createCollection() resolve
|
||||
// undefined for every caller.
|
||||
create: (body: CollectionCreateRequest): Promise<Collection> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
postMultipart(`${base}/${id}/cover`, formData),
|
||||
remove: (id: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
reorder: (orderedIds: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/reorder`, { orderedIds }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
savePlace: (body: CollectionSavePlaceRequest): Promise<CollectionSaveResult> =>
|
||||
ax.post(`${base}/places`, body satisfies CollectionSavePlaceRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTrip: (body: CollectionSaveFromTripRequest): Promise<CollectionSaveResult> =>
|
||||
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<CollectionImportablesResponse> =>
|
||||
ax.get(`${base}/${collectionId}/importable/${tripId}`).then((r: AxiosResponse) => r.data),
|
||||
updatePlace: (pid: number, body: CollectionPlaceUpdateRequest): Promise<CollectionPlace> =>
|
||||
ax.patch(`${base}/places/${pid}`, body satisfies CollectionPlaceUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadPlaceImage: (pid: number, formData: FormData): Promise<CollectionPlace> =>
|
||||
postMultipart(`${base}/places/${pid}/image`, formData),
|
||||
setStatus: (pid: number, status: CollectionStatus): Promise<CollectionPlace> =>
|
||||
ax.post(`${base}/places/${pid}/status`, { status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusMany: (ids: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
ax.post(`${base}/places/status-many`, { ids, status }).then((r: AxiosResponse) => r.data),
|
||||
setStatusFromTrip: (tripId: number, placeIds: number[], status: CollectionStatus): Promise<CollectionSetStatusManyResponse> =>
|
||||
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<CollectionPlace> =>
|
||||
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<unknown> =>
|
||||
ax.delete(`${base}/places/${pid}`).then((r: AxiosResponse) => r.data),
|
||||
deleteMany: (ids: number[]): Promise<unknown> =>
|
||||
ax.post(`${base}/places/delete-many`, { ids }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
copyToTrip: (body: CollectionCopyToTripRequest): Promise<CopyToTripResult> =>
|
||||
ax.post(`${base}/copy-to-trip`, body satisfies CollectionCopyToTripRequest).then((r: AxiosResponse) => r.data),
|
||||
membership: (params: MembershipQuery): Promise<CollectionMembership> =>
|
||||
ax.get(`${base}/membership`, { params }).then((r: AxiosResponse) => r.data),
|
||||
|
||||
invite: (collectionId: number, userId: number, role?: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/invite`, { collection_id: collectionId, user_id: userId, role } satisfies CollectionInviteRequest).then((r: AxiosResponse) => r.data),
|
||||
setMemberRole: (collectionId: number, userId: number, role: CollectionRole): Promise<unknown> =>
|
||||
ax.post(`${base}/members/role`, { collection_id: collectionId, user_id: userId, role }).then((r: AxiosResponse) => r.data),
|
||||
acceptInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/accept`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
declineInvite: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/decline`, { collection_id: collectionId } satisfies CollectionInviteActionRequest).then((r: AxiosResponse) => r.data),
|
||||
cancelInvite: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/invite/cancel`, { collection_id: collectionId, user_id: userId } satisfies CollectionInviteCancelRequest).then((r: AxiosResponse) => r.data),
|
||||
leave: (collectionId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/leave`, { collection_id: collectionId }).then((r: AxiosResponse) => r.data),
|
||||
removeMember: (collectionId: number, userId: number): Promise<unknown> =>
|
||||
ax.post(`${base}/members/remove`, { collection_id: collectionId, user_id: userId }).then((r: AxiosResponse) => r.data),
|
||||
availableUsers: (id: number): Promise<{ users: { id: number; username: string }[] }> =>
|
||||
ax.get(`${base}/${id}/available-users`).then((r: AxiosResponse) => r.data),
|
||||
|
||||
createLabel: (collectionId: number, name: string, color?: string): Promise<CollectionLabel> =>
|
||||
ax.post(`${base}/labels`, { collection_id: collectionId, name, color } satisfies CollectionLabelCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
updateLabel: (labelId: number, body: CollectionLabelUpdateRequest): Promise<CollectionLabel> =>
|
||||
ax.patch(`${base}/labels/${labelId}`, body satisfies CollectionLabelUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
deleteLabel: (labelId: number): Promise<unknown> =>
|
||||
ax.delete(`${base}/labels/${labelId}`).then((r: AxiosResponse) => r.data),
|
||||
assignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/assign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
unassignLabels: (labelIds: number[], placeIds: number[]): Promise<{ changed: number }> =>
|
||||
ax.post(`${base}/labels/unassign`, { label_ids: labelIds, place_ids: placeIds }).then((r: AxiosResponse) => r.data),
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
/**
|
||||
* FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041
|
||||
* for how long the browser waits.
|
||||
*
|
||||
* `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what
|
||||
* goes into the body or the query string. There is no logic in it to get wrong,
|
||||
* which is exactly why a mistake here is so quiet: a wrong path or a dropped
|
||||
* query parameter does not throw, it hands the layer above an empty answer, and
|
||||
* the panel three levels up simply shows nothing. Every method is therefore
|
||||
* driven through MSW and its verb, path and payload pinned.
|
||||
*
|
||||
* Three things here matter more than the URL bookkeeping:
|
||||
*
|
||||
* - **`offset`.** Recorded points are bare instants, so the reader's own UTC
|
||||
* offset is the one fact the server cannot derive. Both track calls send it,
|
||||
* and they send it negated: `getTimezoneOffset()` counts minutes *behind*
|
||||
* local time, the wire wants minutes *ahead* of UTC. Were it to stop being
|
||||
* sent, or to be sent unsigned, an evening east of UTC would land on the
|
||||
* previous day of the trip and nothing would fail anywhere. The line would
|
||||
* just be drawn on the wrong day, which is the whole reason the parameter
|
||||
* exists. Hence two zones rather than one: a single European fixture cannot
|
||||
* tell a negation from a magnitude.
|
||||
* - **The failure reaches the caller intact.** `useDawarichConnection` and
|
||||
* `useDawarichSuggestions` both translate `err.response.data.code` into the
|
||||
* reader's language. A `.catch()` added anywhere in this file would turn
|
||||
* every upstream failure into a silent success with an undefined body.
|
||||
* - **An abort really cancels.** The track overlay issues a fresh request
|
||||
* whenever the looked-at window changes and aborts the previous one; a call
|
||||
* that resolved anyway would repaint the map with the older answer.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay, type JsonBodyType } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich'
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
const BASE = '/api/integrations/dawarich'
|
||||
|
||||
let requestUrl = ''
|
||||
let requestBody: unknown
|
||||
|
||||
/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */
|
||||
function record<T extends JsonBodyType>(data: T) {
|
||||
return async ({ request }: { request: Request }) => {
|
||||
requestUrl = request.url
|
||||
const text = await request.text()
|
||||
requestBody = text ? JSON.parse(text) : undefined
|
||||
return HttpResponse.json(data)
|
||||
}
|
||||
}
|
||||
|
||||
function query(): URLSearchParams {
|
||||
return new URL(requestUrl).searchParams
|
||||
}
|
||||
|
||||
const connection: DawarichConnection = {
|
||||
url: 'https://dawarich.example',
|
||||
apiKeyMasked: '••••••••',
|
||||
allowInsecureTls: false,
|
||||
syncEnabled: true,
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
capabilities: null,
|
||||
}
|
||||
|
||||
const suggestion: DawarichSuggestion = {
|
||||
id: 41,
|
||||
sourceVisitId: 'v-41',
|
||||
tripId: 7,
|
||||
tripTitle: 'Koeln',
|
||||
name: 'Cafe Reichard',
|
||||
lat: 50.94,
|
||||
lng: 6.96,
|
||||
startedAt: '2026-09-10T10:15:00+02:00',
|
||||
endedAt: '2026-09-10T12:40:00+02:00',
|
||||
durationMinutes: 145,
|
||||
localDate: '2026-09-10',
|
||||
sourceStatus: 'suggested',
|
||||
confidence: 82,
|
||||
confidenceBand: 'high',
|
||||
state: 'new',
|
||||
target: null,
|
||||
acceptedPlaceId: null,
|
||||
acceptedJournalEntryId: null,
|
||||
acceptedBucketListItemId: null,
|
||||
sourceChanged: false,
|
||||
sourceMissing: false,
|
||||
matchedBucketListItemId: null,
|
||||
matchedBucketListName: null,
|
||||
countryCode: 'DE',
|
||||
firstSeenAt: '2026-09-10T06:00:00Z',
|
||||
lastSeenAt: '2026-09-10T06:00:00Z',
|
||||
}
|
||||
|
||||
const suggestionList: DawarichSuggestionList = {
|
||||
suggestions: [suggestion],
|
||||
connected: true,
|
||||
lastSyncAt: '2026-09-13T21:00:00Z',
|
||||
lastSyncState: 'ok',
|
||||
lastSyncError: null,
|
||||
}
|
||||
|
||||
const track: DawarichTrack = {
|
||||
days: [{ date: '2026-09-10', segments: [] }],
|
||||
source: 'tracks',
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
pointCount: 0,
|
||||
truncated: false,
|
||||
}
|
||||
|
||||
const bucketScan: DawarichBucketScan = {
|
||||
matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }],
|
||||
skippedWithoutCoordinates: 1,
|
||||
truncated: false,
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
const atlas: DawarichAtlasSuggestions = {
|
||||
countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }],
|
||||
unresolved: ['Kosovo'],
|
||||
fetchedAt: '2026-09-13T21:05:00Z',
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestUrl = ''
|
||||
requestBody = undefined
|
||||
// Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the
|
||||
// value on the wire is +120 (positive for a zone ahead of UTC), which is the
|
||||
// sign convention the server's day resolution expects.
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('dawarichApi connection', () => {
|
||||
it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => {
|
||||
server.use(http.get(`${BASE}/settings`, record(connection)))
|
||||
|
||||
const res = await dawarichApi.getSettings()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
expect(res).toEqual(connection)
|
||||
})
|
||||
|
||||
// The three warning fields are why this endpoint does not just answer
|
||||
// `{ success }`: the server writes its sentence in English, the client renders
|
||||
// the code in the reader's language and quotes the resolved address back.
|
||||
// Dropping any of them leaves a private-IP connection looking perfectly healthy.
|
||||
it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => {
|
||||
server.use(
|
||||
http.put(
|
||||
`${BASE}/settings`,
|
||||
record({
|
||||
success: true,
|
||||
warning: 'That host resolves to a private address.',
|
||||
warningCode: 'private_ip',
|
||||
warningIp: '192.168.178.72',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.saveSettings({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
|
||||
expect(requestBody).toEqual({
|
||||
url: 'https://dawarich.lan',
|
||||
apiKey: 'secret',
|
||||
allowInsecureTls: true,
|
||||
syncEnabled: false,
|
||||
})
|
||||
expect(res.warningCode).toBe('private_ip')
|
||||
expect(res.warningIp).toBe('192.168.178.72')
|
||||
expect(res.warning).toContain('private address')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => {
|
||||
server.use(http.delete(`${BASE}/settings`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.disconnect()).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/settings`)
|
||||
})
|
||||
|
||||
// A probe runs before anything is stored, so the body is deliberately partial:
|
||||
// a url with no key is the shape the connect dialog sends on its first attempt.
|
||||
it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => {
|
||||
server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' })))
|
||||
|
||||
const res = await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
|
||||
expect(requestBody).toEqual({ url: 'https://dawarich.example' })
|
||||
expect(res.connected).toBe(false)
|
||||
expect(res.error).toBe('unauthorized')
|
||||
})
|
||||
|
||||
// `alreadyRunning` is how a second press of Sync Now is told apart from a sync
|
||||
// that genuinely found nothing; both report created/updated/missing as zero.
|
||||
it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => {
|
||||
server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true })))
|
||||
|
||||
const res = await dawarichApi.syncNow()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/sync`)
|
||||
expect(res.alreadyRunning).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi suggestions', () => {
|
||||
it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => {
|
||||
server.use(http.get(`${BASE}/suggestions`, record(suggestionList)))
|
||||
|
||||
const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' })
|
||||
expect(query().get('tripId')).toBe('7')
|
||||
expect(query().get('state')).toBe('new')
|
||||
expect(res.suggestions).toHaveLength(1)
|
||||
|
||||
await dawarichApi.listSuggestions()
|
||||
expect(new URL(requestUrl).search).toBe('')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => {
|
||||
server.use(
|
||||
http.post(
|
||||
`${BASE}/suggestions/:id/accept`,
|
||||
record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }),
|
||||
),
|
||||
)
|
||||
|
||||
const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`)
|
||||
expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' })
|
||||
expect(res.createdPlaceId).toBe(900)
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => {
|
||||
server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' })))
|
||||
|
||||
const res = await dawarichApi.setState(41, 'dismissed')
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/suggestions/41/state`)
|
||||
expect(requestBody).toEqual({ state: 'dismissed' })
|
||||
expect(res.state).toBe('dismissed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi bucket list', () => {
|
||||
it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan)))
|
||||
|
||||
const res = await dawarichApi.scanBucketList()
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/scan`)
|
||||
expect(res.skippedWithoutCoordinates).toBe(1)
|
||||
})
|
||||
|
||||
// `visitedAt` is optional because the default is "whenever the recording says",
|
||||
// which only the server knows. An omitted one must not turn into a null on the
|
||||
// wire, which the server would read as an instruction to store no date at all.
|
||||
it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => {
|
||||
server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 })))
|
||||
|
||||
const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z')
|
||||
expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' })
|
||||
expect(res.updated).toBe(2)
|
||||
|
||||
await dawarichApi.confirmBucketVisits([3])
|
||||
expect(requestBody).toEqual({ itemIds: [3] })
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => {
|
||||
server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true })))
|
||||
|
||||
expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true })
|
||||
expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi atlas', () => {
|
||||
it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => {
|
||||
server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas)))
|
||||
|
||||
const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31')
|
||||
|
||||
expect(query().get('from')).toBe('2026-01-01')
|
||||
expect(query().get('to')).toBe('2026-12-31')
|
||||
expect(res.unresolved).toEqual(['Kosovo'])
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => {
|
||||
server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 })))
|
||||
|
||||
const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL'])
|
||||
|
||||
expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] })
|
||||
expect(res.marked).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi track overlay', () => {
|
||||
it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' })
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.source).toBe('tracks')
|
||||
})
|
||||
|
||||
// The whole-trip case passes no range at all. The offset still has to be there:
|
||||
// without it the server has to guess a day boundary for every recorded instant.
|
||||
it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => {
|
||||
server.use(http.get(`${BASE}/trips/:id/track`, record(track)))
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
|
||||
expect([...query().keys()]).toEqual(['offset'])
|
||||
expect(query().get('offset')).toBe('120')
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
|
||||
expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`)
|
||||
expect(query().get('from')).toBe('2026-09-10')
|
||||
expect(query().get('to')).toBe('2026-09-12')
|
||||
expect(query().get('offset')).toBe('120')
|
||||
expect(res.pointCount).toBe(0)
|
||||
})
|
||||
|
||||
// The health handler is here because the axios error interceptor probes
|
||||
// reachability whenever an error carries no response, and a cancellation is
|
||||
// exactly that. Answering the probe keeps the detour short; whichever verdict
|
||||
// it comes back with, the cancellation has to be re-rejected untouched, and
|
||||
// that is what this pins.
|
||||
it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => {
|
||||
server.use(
|
||||
http.get('/api/health', () => HttpResponse.json({ status: 'ok' })),
|
||||
http.get(`${BASE}/track`, async () => {
|
||||
await delay(50)
|
||||
return HttpResponse.json(track)
|
||||
}),
|
||||
)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal)
|
||||
ctrl.abort()
|
||||
|
||||
await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' })
|
||||
})
|
||||
|
||||
// Nothing in this file catches, on purpose: the connection card and the
|
||||
// suggestions hook both read the upstream reason off `response.data.code` to
|
||||
// say it in the reader's language. A swallowed rejection would reach them as a
|
||||
// successful call carrying an undefined body.
|
||||
it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/suggestions`, () =>
|
||||
HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({
|
||||
response: { status: 502, data: { code: 'unreachable' } },
|
||||
})
|
||||
})
|
||||
|
||||
// Every other test here runs in Berlin, where -120 negated and -120 made
|
||||
// positive look identical. New York separates them: the platform reports +300
|
||||
// and the wire has to carry -300. A `Math.abs` slipped in would leave every
|
||||
// European install working perfectly and put every American one a day out,
|
||||
// which is the kind of bug that gets reported as "sometimes wrong".
|
||||
//
|
||||
// UTC is the second half of the pair: the offset there is falsy, and a guard
|
||||
// that only sends it "when there is one" would drop it exactly for the readers
|
||||
// whose day boundary the server would then have to guess.
|
||||
it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => {
|
||||
server.use(http.get(`${BASE}/track`, record(track)))
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('-300')
|
||||
|
||||
vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0)
|
||||
await dawarichApi.windowTrack('2026-09-10', '2026-09-12')
|
||||
expect(query().get('offset')).toBe('0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dawarichApi: how long the browser waits', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => {
|
||||
// The server gives each request to Dawarich 15 s on its own. At the shared
|
||||
// 8 s the browser gave up first, the server cached the answer anyway, and the
|
||||
// recorded route turned up only after a reload.
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.tripTrack(7)
|
||||
await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z')
|
||||
await dawarichApi.test({ url: 'https://dawarich.example' })
|
||||
await dawarichApi.syncNow()
|
||||
await dawarichApi.scanBucketList()
|
||||
await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true })
|
||||
|
||||
const configs = [
|
||||
...get.mock.calls.map(call => call[1]),
|
||||
...post.mock.calls.map(call => call[2]),
|
||||
...put.mock.calls.map(call => call[2]),
|
||||
]
|
||||
expect(configs).toHaveLength(6)
|
||||
for (const config of configs) {
|
||||
expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }))
|
||||
}
|
||||
expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000)
|
||||
// The track calls still carry their offset alongside the longer wait.
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) }))
|
||||
})
|
||||
|
||||
it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => {
|
||||
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} })
|
||||
const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} })
|
||||
|
||||
await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z')
|
||||
expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS }))
|
||||
expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS)
|
||||
|
||||
// Reading suggestions and confirming countries touch TREK's own database
|
||||
// only; a two-minute wait there would just hide a stuck server.
|
||||
await dawarichApi.listSuggestions()
|
||||
await dawarichApi.acceptAtlasCountries(['DE'])
|
||||
expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() }))
|
||||
expect(post.mock.calls[0][2]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,107 +0,0 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
DawarichAccept,
|
||||
DawarichAcceptResult,
|
||||
DawarichAtlasSuggestions,
|
||||
DawarichBucketScan,
|
||||
DawarichConnection,
|
||||
DawarichSettings,
|
||||
DawarichStatus,
|
||||
DawarichSuggestion,
|
||||
DawarichSuggestionList,
|
||||
DawarichTrack,
|
||||
} from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The Dawarich surface, in its own file rather than in the `client.ts` god
|
||||
* module. Types come from the shared contracts, so a server change that the
|
||||
* client has not caught up with is a typecheck failure rather than a runtime
|
||||
* surprise.
|
||||
*/
|
||||
/**
|
||||
* How long the browser waits for a call that makes the server go and ask
|
||||
* Dawarich.
|
||||
*
|
||||
* The shared 8 s on `apiClient` is right for TREK's own routes and wrong for
|
||||
* these: the server allows each request to Dawarich 15 s by itself, walks pages
|
||||
* of points for a route, and runs six probes one after another to test a
|
||||
* connection. Cut off at 8 s, the browser gave up while the server carried on
|
||||
* and cached the answer, so the recorded route showed "unavailable" and then
|
||||
* appeared on the next reload.
|
||||
*/
|
||||
export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000
|
||||
|
||||
/**
|
||||
* The Atlas asks for a year of visited cities a month at a time, one request
|
||||
* after another, so it gets longer again. Still bounded, and under Node's own
|
||||
* five-minute request limit on the server side.
|
||||
*/
|
||||
export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000
|
||||
|
||||
const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS }
|
||||
|
||||
export const dawarichApi = {
|
||||
// ── Connection ─────────────────────────────────────────────────────────────
|
||||
getSettings: (): Promise<DawarichConnection> =>
|
||||
apiClient.get('/integrations/dawarich/settings').then(r => r.data),
|
||||
saveSettings: (data: DawarichSettings): Promise<{
|
||||
success: boolean
|
||||
/** The server's own sentence, English — the fallback when the code below is unknown here. */
|
||||
warning?: string
|
||||
/** What the warning is about, so the UI can say it in the reader's language. */
|
||||
warningCode?: string
|
||||
/** The address it resolved to, for the private-IP warning. */
|
||||
warningIp?: string
|
||||
}> =>
|
||||
apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data),
|
||||
disconnect: (): Promise<{ success: boolean }> =>
|
||||
apiClient.delete('/integrations/dawarich/settings').then(r => r.data),
|
||||
test: (data: Partial<DawarichSettings>): Promise<DawarichStatus> =>
|
||||
apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data),
|
||||
syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> =>
|
||||
apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data),
|
||||
|
||||
// ── Suggestions ────────────────────────────────────────────────────────────
|
||||
listSuggestions: (params?: { tripId?: number; state?: string }): Promise<DawarichSuggestionList> =>
|
||||
apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data),
|
||||
accept: (id: number, body: DawarichAccept): Promise<DawarichAcceptResult> =>
|
||||
apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data),
|
||||
setState: (id: number, state: 'new' | 'dismissed'): Promise<DawarichSuggestion> =>
|
||||
apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data),
|
||||
|
||||
// ── Bucket list ────────────────────────────────────────────────────────────
|
||||
scanBucketList: (): Promise<DawarichBucketScan> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data),
|
||||
confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> =>
|
||||
apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data),
|
||||
clearBucketVisit: (itemId: number): Promise<{ success: boolean }> =>
|
||||
apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data),
|
||||
|
||||
// ── Atlas ──────────────────────────────────────────────────────────────────
|
||||
atlasSuggestions: (from: string, to: string): Promise<DawarichAtlasSuggestions> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS })
|
||||
.then(r => r.data),
|
||||
acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> =>
|
||||
apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data),
|
||||
|
||||
// ── Track overlay ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Recorded points are bare instants, so the server cannot know which day they
|
||||
// belong to without being told whose day is meant. The browser is the only
|
||||
// party that knows, so it says so — otherwise an evening east of UTC lands on
|
||||
// the wrong day of the trip.
|
||||
tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
windowTrack: (from: string, to: string, signal?: AbortSignal): Promise<DawarichTrack> =>
|
||||
apiClient
|
||||
.get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal })
|
||||
.then(r => r.data),
|
||||
}
|
||||
|
||||
/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */
|
||||
function localOffsetMinutes(): number {
|
||||
return -new Date().getTimezoneOffset()
|
||||
}
|
||||
@@ -1,26 +1,12 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-010
|
||||
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, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } 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])
|
||||
}
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
|
||||
|
||||
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',
|
||||
@@ -30,8 +16,6 @@ describe('SCOPE_GROUPS', () => {
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
'plugins:use',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -48,16 +32,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
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-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
@@ -124,26 +100,3 @@ describe('getScopesByGroup', () => {
|
||||
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client presets', () => {
|
||||
// The presets are written as "everything except deletes", so a new scope joins
|
||||
// them silently. That is right for a scope over the user's own data and wrong
|
||||
// for one that runs third-party code, so the exclusion gets a test.
|
||||
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
|
||||
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
|
||||
for (const scope of PRESET_OPT_IN_ONLY) {
|
||||
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
|
||||
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
|
||||
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
|
||||
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
|
||||
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,6 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'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' },
|
||||
@@ -43,32 +41,10 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' },
|
||||
'files:read': { labelKey: 'oauth.scope.files:read.label', descriptionKey: 'oauth.scope.files:read.description', groupKey: 'oauth.scope.group.files' },
|
||||
'files:write': { labelKey: 'oauth.scope.files:write.label', descriptionKey: 'oauth.scope.files:write.description', groupKey: 'oauth.scope.group.files' },
|
||||
'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' },
|
||||
'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
/**
|
||||
* Scopes a client preset must never tick for you.
|
||||
*
|
||||
* The presets are written as "everything except deletes", so a new scope joins
|
||||
* them silently. That is right for a new read or write scope on data the user
|
||||
* already owns, and wrong for plugins:use: it turns on third-party tool
|
||||
* execution, which nobody would have chosen by installing an editor.
|
||||
*/
|
||||
export const PRESET_OPT_IN_ONLY = new Set(['plugins:use'])
|
||||
|
||||
/** The five full-access presets: everything destructive or opt-in-only removed. */
|
||||
export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */
|
||||
export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
// Group all scopes for the client registration form
|
||||
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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 { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared'
|
||||
import apiClient from './client'
|
||||
|
||||
const base = '/school-holiday-catalog'
|
||||
export const schoolHolidayCatalogApi = {
|
||||
catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)),
|
||||
region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country),
|
||||
deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`),
|
||||
createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)),
|
||||
deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }),
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// FE-API-UPLOAD-001 to FE-API-UPLOAD-013
|
||||
//
|
||||
// The shared axios instance carries timeout: 8000, and axios' timeout is a whole-request
|
||||
// deadline — not an idle one. Any upload whose body takes longer than 8s to push is
|
||||
// aborted mid-stream and the server reports a multer "Request aborted" (#1495).
|
||||
//
|
||||
// The original fix added `timeout: 0` to the three cover uploads by hand, which left the
|
||||
// same bug live on 7 other endpoints — including the two that accept 500 MB (documents
|
||||
// and backup restore). Every multipart call now goes through postMultipart(), so this
|
||||
// suite pins ALL of them, not just the covers.
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
apiClient,
|
||||
authApi,
|
||||
tripsApi,
|
||||
placesApi,
|
||||
adminApi,
|
||||
journeyApi,
|
||||
filesApi,
|
||||
reservationsApi,
|
||||
collabApi,
|
||||
backupApi,
|
||||
} from './client'
|
||||
import { collectionsApi } from './collections'
|
||||
|
||||
describe('every multipart upload disables the global request timeout', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function spyPost() {
|
||||
return vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} } as any)
|
||||
}
|
||||
|
||||
const fd = () => new FormData()
|
||||
const file = () => new File(['x'], 'f.bin')
|
||||
|
||||
// [id, description, invoke, expected url]
|
||||
const cases: [string, string, () => Promise<unknown>, string][] = [
|
||||
['FE-API-UPLOAD-001', 'authApi.uploadAvatar (5 MB)', () => authApi.uploadAvatar(fd()), '/auth/avatar'],
|
||||
['FE-API-UPLOAD-002', 'tripsApi.uploadCover (20 MB)', () => tripsApi.uploadCover(7, fd()), '/trips/7/cover'],
|
||||
['FE-API-UPLOAD-003', 'placesApi.importGpx (10 MB)', () => placesApi.importGpx(7, file()), '/trips/7/places/import/gpx'],
|
||||
['FE-API-UPLOAD-004', 'placesApi.importMapFile (10 MB)', () => placesApi.importMapFile(7, file()), '/trips/7/places/import/map'],
|
||||
['FE-API-UPLOAD-005', 'adminApi.pluginUpload (50 MB)', () => adminApi.pluginUpload(file()), '/admin/plugins/upload'],
|
||||
['FE-API-UPLOAD-006', 'journeyApi.uploadCover (20 MB)', () => journeyApi.uploadCover(7, fd()), '/journeys/7/cover'],
|
||||
['FE-API-UPLOAD-007', 'journeyApi.uploadPhotos (20 MB)', () => journeyApi.uploadPhotos(7, fd()), '/journeys/entries/7/photos'],
|
||||
['FE-API-UPLOAD-008', 'journeyApi.uploadGalleryVideo (500 MB)', () => journeyApi.uploadGalleryVideo(7, fd()), '/journeys/7/gallery/video'],
|
||||
['FE-API-UPLOAD-009', 'filesApi.upload (500 MB)', () => filesApi.upload(7, fd()), '/trips/7/files'],
|
||||
['FE-API-UPLOAD-010', 'collabApi.uploadNoteFile (50 MB)', () => collabApi.uploadNoteFile(7, 3, fd()), '/trips/7/collab/notes/3/files'],
|
||||
['FE-API-UPLOAD-011', 'backupApi.uploadRestore (500 MB)', () => backupApi.uploadRestore(file()), '/backup/upload-restore'],
|
||||
['FE-API-UPLOAD-012', 'collectionsApi.uploadCover (20 MB)', () => collectionsApi.uploadCover(7, fd()), '/addons/collections/7/cover'],
|
||||
]
|
||||
|
||||
for (const [id, desc, invoke, url] of cases) {
|
||||
it(`${id}: ${desc} posts with timeout 0`, async () => {
|
||||
const post = spyPost()
|
||||
await invoke()
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
url,
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ timeout: 0 }),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('FE-API-UPLOAD-013: reservationsApi booking import posts with timeout 0', async () => {
|
||||
const post = spyPost()
|
||||
await reservationsApi.importBookingPreview(7, [file()])
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/trips/7/reservations/import/booking',
|
||||
expect.any(FormData),
|
||||
expect.objectContaining({ timeout: 0 }),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,267 +0,0 @@
|
||||
// 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<MockWebSocket> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -9,15 +9,6 @@ let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
|
||||
/**
|
||||
* 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<string>()
|
||||
let shouldReconnect = false
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
@@ -29,12 +20,6 @@ export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||
* trip's store after the network comes back via the `online` event. */
|
||||
export function getActiveTrips(): string[] {
|
||||
return Array.from(activeTrips)
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
@@ -119,11 +104,6 @@ async function connectInternal(_isReconnect = false): Promise<void> {
|
||||
|
||||
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) {
|
||||
@@ -180,7 +160,6 @@ export function disconnect(): void {
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
activeBooks.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
@@ -202,40 +181,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
'collab:poll:deleted',
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
'roadtripPreferences:changed',
|
||||
// components/Roadtrip/useDayBoundaries reloads the shared boundaries.
|
||||
'roadtripBoundary:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
// Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel
|
||||
// and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries
|
||||
// no state to apply, which is the point: the totals are summed server-side across
|
||||
// members whose items this client may not see.
|
||||
'packing:bag-totals',
|
||||
// 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',
|
||||
// Document sync: components/Files/docsync/useDocSync re-reads the bindings
|
||||
// and their counts, and components/Files/docsync/useDocSyncOffered asks
|
||||
// again whether the Files screen offers the sync button. Content-free like
|
||||
// the bag ping: what a run changed is per-binding server state, and the
|
||||
// files it moved arrive as file:* events.
|
||||
'docsync:changed',
|
||||
// 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[]
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-011
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -21,41 +21,6 @@ function buildAddon(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function addonsRoute(addons: ReturnType<typeof buildAddon>[]) {
|
||||
return http.get('/api/admin/addons', () => HttpResponse.json({ addons }));
|
||||
}
|
||||
|
||||
function llmAddon(config: Record<string, unknown> = {}) {
|
||||
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 })) });
|
||||
});
|
||||
}
|
||||
|
||||
/** Every switch is labelled with the thing it controls, top-level tile or shelf
|
||||
* row alike, so one lookup covers both. */
|
||||
function addonToggle(name: string): HTMLElement {
|
||||
return screen.getByRole('button', { name });
|
||||
}
|
||||
|
||||
const subToggle = addonToggle;
|
||||
|
||||
function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.getAttribute('aria-pressed') === 'true';
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -78,7 +43,6 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
document.documentElement.classList.remove('dark');
|
||||
});
|
||||
|
||||
describe('AddonManager', () => {
|
||||
@@ -95,7 +59,7 @@ describe('AddonManager', () => {
|
||||
|
||||
it('FE-ADMIN-ADDON-002: empty state when addons list is empty', async () => {
|
||||
render(<AddonManager />);
|
||||
expect(await screen.findByText('No addons available')).toBeInTheDocument();
|
||||
await screen.findByText('No addons available');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-003: trip addons section renders with correct section header', async () => {
|
||||
@@ -141,10 +105,13 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
const toggleBtn = addonToggle('Todo List');
|
||||
expect(isOn(toggleBtn)).toBe(false);
|
||||
// Get toggle button - use getAllByRole since there might be multiple buttons
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtn).toBeInTheDocument();
|
||||
|
||||
await user.click(toggleBtn);
|
||||
// Before click - disabled state (border-primary bg)
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
// After click - success toast
|
||||
await screen.findByText('Addon updated');
|
||||
@@ -163,14 +130,17 @@ describe('AddonManager', () => {
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const toggleBtn = buttons.find(b => b.classList.contains('rounded-full'));
|
||||
await user.click(toggleBtn!);
|
||||
|
||||
// Error toast appears
|
||||
await screen.findByText('Failed to update addon');
|
||||
|
||||
// The switch is back off after rollback
|
||||
// The disabled text should be back after rollback
|
||||
await waitFor(() => {
|
||||
expect(isOn(addonToggle('Todo List'))).toBe(false);
|
||||
const disabledTexts = screen.getAllByText('Disabled');
|
||||
expect(disabledTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,7 +156,13 @@ describe('AddonManager', () => {
|
||||
<AddonManager bagTrackingEnabled={false} onToggleBagTracking={mockToggle} />
|
||||
);
|
||||
await screen.findByText('Bag Tracking');
|
||||
await user.click(subToggle('Bag Tracking'));
|
||||
const bagTrackingToggle = screen.getAllByRole('button').find(b =>
|
||||
b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking')
|
||||
);
|
||||
// Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking")
|
||||
const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
// There should be two toggle buttons: one for the addon, one for bag tracking
|
||||
await user.click(allBtns[allBtns.length - 1]);
|
||||
expect(mockToggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -236,10 +212,9 @@ describe('AddonManager', () => {
|
||||
// Journey addon is rendered
|
||||
expect(screen.getByText('Journey')).toBeInTheDocument();
|
||||
|
||||
// Journey's own switch plus one per provider, each labelled with what it controls
|
||||
expect(isOn(addonToggle('Journey'))).toBe(true);
|
||||
expect(isOn(subToggle('Unsplash'))).toBe(true);
|
||||
expect(isOn(subToggle('Pexels'))).toBe(false);
|
||||
// Toggle buttons: journey toggle + 2 provider toggles
|
||||
const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full'));
|
||||
expect(toggleBtns.length).toBe(3);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => {
|
||||
@@ -254,555 +229,4 @@ describe('AddonManager', () => {
|
||||
expect(() => render(<AddonManager />)).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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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 () => {
|
||||
// The wordmark reads the .dark class, the same source applyAppearance() writes.
|
||||
document.documentElement.classList.add('dark');
|
||||
render(<AddonManager />);
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
await screen.findByText('Immich');
|
||||
// immich and synologyphotos ship a vendor glyph, unsplash does not
|
||||
const immichRow = screen.getByText('Immich').closest('li') as HTMLElement;
|
||||
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement;
|
||||
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('li') 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(<><ToastContainer /><AddonManager /></>);
|
||||
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(<><ToastContainer /><AddonManager /></>);
|
||||
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-030: provider sub-rows stay hidden while Journey itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
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 }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Journey');
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
let journeyOn = true;
|
||||
let immichOn = true;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/journey', async ({ request }) => {
|
||||
journeyOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
// Journey off takes its providers with it, see admin.service.ts.
|
||||
if (!journeyOn) immichOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Immich');
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
// The toast closes the whole toggle, re-read included.
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(2);
|
||||
expect(screen.queryByText('Immich')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(addonToggle('Journey'));
|
||||
|
||||
await screen.findByText('Immich');
|
||||
expect(isOn(subToggle('Immich'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] });
|
||||
}),
|
||||
http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Todo List');
|
||||
|
||||
await user.click(addonToggle('Todo List'));
|
||||
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(1);
|
||||
expect(isOn(addonToggle('Todo List'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }),
|
||||
buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement;
|
||||
expect(paperlessRow.querySelector('svg')).toBeInTheDocument();
|
||||
expect(isOn(subToggle('Paperless-ngx'))).toBe(true);
|
||||
expect(isOn(subToggle('Nextcloud'))).toBe(false);
|
||||
// Documents and Journey are the addons; an enabled provider is a shelf row
|
||||
// and must not turn 1/2 into 2/2.
|
||||
expect(screen.getByText('1/2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => {
|
||||
server.use(addonsRoute([
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }),
|
||||
]));
|
||||
render(<AddonManager />);
|
||||
|
||||
await screen.findByText('Documents');
|
||||
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
let documentsOn = true;
|
||||
let paperlessOn = true;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }),
|
||||
buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/documents', async ({ request }) => {
|
||||
documentsOn = (await request.json() as { enabled: boolean }).enabled;
|
||||
if (!documentsOn) paperlessOn = false;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Paperless-ngx');
|
||||
|
||||
await user.click(addonToggle('Documents'));
|
||||
await screen.findByText('Addon updated');
|
||||
expect(loads).toBe(2);
|
||||
expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(addonToggle('Documents'));
|
||||
|
||||
await screen.findByText('Paperless-ngx');
|
||||
expect(isOn(subToggle('Paperless-ngx'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loads = 0;
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
http.get('/api/admin/addons', () => {
|
||||
loads += 1;
|
||||
return HttpResponse.json({
|
||||
addons: [
|
||||
buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }),
|
||||
buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }),
|
||||
],
|
||||
});
|
||||
}),
|
||||
http.put('/api/admin/addons/papra', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><AddonManager /></>);
|
||||
await screen.findByText('Papra');
|
||||
|
||||
await user.click(subToggle('Papra'));
|
||||
|
||||
await screen.findByText('Addon updated');
|
||||
expect(body).toEqual({ enabled: true });
|
||||
expect(loads).toBe(1);
|
||||
expect(isOn(subToggle('Papra'))).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(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
|
||||
onToggleCollabFeature={onToggleCollabFeature}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
await screen.findByText('Collab');
|
||||
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => {
|
||||
// The row's tooltip is its description. The links tab's empty state read as a
|
||||
// status here, and a status that never changes reads as a broken feature.
|
||||
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
|
||||
render(
|
||||
<AddonManager
|
||||
collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }}
|
||||
onToggleCollabFeature={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const links = await screen.findByText('Links');
|
||||
expect(links).toHaveAttribute('title');
|
||||
expect(links.getAttribute('title')).not.toBe('No shared links yet');
|
||||
});
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
await screen.findByText('AI Parsing');
|
||||
// The catalog key now exists, so the tile shows the translation rather than falling
|
||||
// back to the English description the server sent.
|
||||
expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Provider')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => {
|
||||
server.use(addonsRoute([llmAddon({ provider: 'openai' })]));
|
||||
render(<AddonManager />);
|
||||
|
||||
const label = await screen.findByText('Provider');
|
||||
// Inside the AI Parsing tile's <article>, like the collab toggles sit in theirs.
|
||||
const tile = label.closest('article');
|
||||
expect(tile).not.toBeNull();
|
||||
expect(tile!.textContent).toContain('AI Parsing');
|
||||
});
|
||||
|
||||
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.5:4b', 'llama3:8b'], urls));
|
||||
render(<AddonManager />);
|
||||
|
||||
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.5:4b 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.5:4b');
|
||||
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(<AddonManager />);
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
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.5:4b', 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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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.5:4b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b');
|
||||
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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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(<><ToastContainer /><AddonManager /></>);
|
||||
|
||||
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(<AddonManager />);
|
||||
|
||||
await screen.findByText('Provider');
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { useEffect, useState, type ComponentType } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useIsDark } from '../../hooks/useIsDark'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import DawarichIcon from '../shared/DawarichIcon'
|
||||
import AirTrailIcon from '../shared/AirTrailIcon'
|
||||
import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons'
|
||||
import AddonTile from './AddonTile'
|
||||
import AddonSubRow from './AddonSubRow'
|
||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
||||
|
||||
// Keys are the `icon` column from the addons table (see server seeds.ts); anything
|
||||
// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which
|
||||
// used to land on the fallback.
|
||||
const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles,
|
||||
// Dawarich ships a brand mark rather than a lucide name, the same way the photo
|
||||
// providers below do. Keyed by the string in the addons table.
|
||||
Dawarich: DawarichIcon,
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
||||
}
|
||||
|
||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||
@@ -39,12 +26,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) {
|
||||
)
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<string, ComponentType<{ size?: number }>> = {
|
||||
const PROVIDER_ICONS: Record<string, React.FC<{ size?: number }>> = {
|
||||
immich: ImmichIcon,
|
||||
synologyphotos: SynologyIcon,
|
||||
// The document providers' own marks, single-colour so they read as glyphs in
|
||||
// a row of lucide icons and invert with the theme.
|
||||
...DOCUMENT_PROVIDER_ICONS,
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
@@ -68,100 +52,89 @@ interface ProviderOption {
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
/** A switched-off addon greys its icon out, brand marks included. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) {
|
||||
// The brand marks are badges, not glyphs: they fill the framed slot instead of
|
||||
// sitting in the middle of it at glyph size, and they grey out when the addon
|
||||
// is off — the lucide glyphs get that from the slot's text colour, an <img>
|
||||
// has to be told.
|
||||
if (name === 'Dawarich') return <DawarichIcon fill muted={!enabled} />
|
||||
// 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE
|
||||
// means every existing install still carries it — so the brand is keyed on
|
||||
// that rather than on a new name no row would ever have.
|
||||
if (name === 'Plane') return <AirTrailIcon fill muted={!enabled} />
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
/** What each type means, shown on the tile itself now that the sections are gone. The
|
||||
* hint rides along as the tooltip, so "Global" still explains itself. */
|
||||
const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelKey: string; hintKey: string }> = {
|
||||
trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' },
|
||||
global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' },
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'admin.collab.links.subtitle' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) {
|
||||
const { t } = useTranslation()
|
||||
const dark = useIsDark()
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const refreshGlobalAddons = useAddonStore(s => s.loadAddons)
|
||||
const [addons, setAddons] = useState<Addon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAddons().finally(() => setLoading(false))
|
||||
loadAddons()
|
||||
}, [])
|
||||
|
||||
const loadAddons = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic flip, per-row rollback on failure. Rolling the whole addons
|
||||
* snapshot back instead would undo a toggle the user hit in parallel. */
|
||||
const handleToggle = async (addon: Addon) => {
|
||||
const newEnabled = !addon.enabled
|
||||
// Optimistic update
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||
try {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
return
|
||||
}
|
||||
refreshGlobalAddons()
|
||||
// Journey off disables every photo provider with it, and the response carries
|
||||
// only Journey. Without re-reading, switching Journey back on brings the shelf
|
||||
// up with providers the database has long since turned off.
|
||||
// Same for Documents: switching it off disables every document provider in
|
||||
// the database, and the response carries only the addon itself.
|
||||
if (addon.id === 'journey' || addon.id === 'documents') await loadAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
}
|
||||
|
||||
const isPhotoProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'photo_provider'
|
||||
}
|
||||
|
||||
const isDocumentProviderAddon = (addon: Addon) => {
|
||||
return addon.type === 'document_provider'
|
||||
}
|
||||
|
||||
const isPhotosAddon = (addon: Addon) => {
|
||||
const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase()
|
||||
return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories'))
|
||||
}
|
||||
|
||||
const handleTogglePhotoProvider = async (providerAddon: Addon) => {
|
||||
const enableProvider = !providerAddon.enabled
|
||||
const prev = addons
|
||||
|
||||
setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a))
|
||||
|
||||
try {
|
||||
await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider })
|
||||
refreshGlobalAddons()
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch {
|
||||
setAddons(prev)
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const photoProviderAddons = addons.filter(isPhotoProviderAddon)
|
||||
const documentProviderAddons = addons.filter(isDocumentProviderAddon)
|
||||
const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon)
|
||||
const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a))
|
||||
const globalAddons = addons.filter(a => a.type === 'global')
|
||||
const integrationAddons = addons.filter(a => a.type === 'integration')
|
||||
@@ -170,399 +143,180 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleToggle(provider),
|
||||
}))
|
||||
const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({
|
||||
key: provider.id,
|
||||
label: provider.name,
|
||||
description: provider.description,
|
||||
enabled: provider.enabled,
|
||||
toggle: () => handleToggle(provider),
|
||||
toggle: () => handleTogglePhotoProvider(provider),
|
||||
}))
|
||||
const photosDerivedEnabled = providerOptions.some(p => p.enabled)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="grid place-items-center py-16">
|
||||
<Loader2 size={22} className="animate-spin text-content-faint" />
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bag tracking, the collab features and the photo providers all hang off their
|
||||
* parent as shelf rows, and each shelf only shows while its parent is on —
|
||||
* a provider toggled under a disabled Journey would hit the server's 409. */
|
||||
const shelfFor = (addon: Addon) => {
|
||||
if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) {
|
||||
return (
|
||||
<AddonSubRow
|
||||
icon={<Luggage size={14} />}
|
||||
title={t('admin.bagTracking.title')}
|
||||
description={t('admin.bagTracking.subtitle')}
|
||||
enabled={!!bagTrackingEnabled}
|
||||
onToggle={onToggleBagTracking}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) {
|
||||
return COLLAB_SUB_FEATURES.map(feat => {
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={feat.key}
|
||||
icon={<Icon size={14} />}
|
||||
title={t(feat.titleKey)}
|
||||
description={t(feat.subtitleKey)}
|
||||
enabled={collabFeatures[feat.key]}
|
||||
onToggle={() => onToggleCollabFeature(feat.key)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
// Document providers are the Documents tile's shelf, the way photo providers
|
||||
// are Journey's. Unlike those they carry no credential form here: a document
|
||||
// connection belongs to a trip, not to a user, so it is entered in the trip's
|
||||
// file manager. The admin decides only whether a provider may be offered.
|
||||
if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) {
|
||||
return documentProviderOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={provider.key}
|
||||
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
|
||||
title={provider.label}
|
||||
description={provider.description}
|
||||
enabled={provider.enabled}
|
||||
onToggle={provider.toggle}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) {
|
||||
return providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<AddonSubRow
|
||||
key={provider.key}
|
||||
icon={ProviderIcon ? <ProviderIcon size={14} /> : undefined}
|
||||
title={provider.label}
|
||||
description={provider.description}
|
||||
enabled={provider.enabled}
|
||||
onToggle={provider.toggle}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
// The AI-parsing settings hang off their tile like every other sub-feature,
|
||||
// just as a form instead of toggle rows.
|
||||
if (addon.id === 'llm_parsing' && addon.enabled) {
|
||||
return <LlmParsingConfig addon={addon} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const tile = (addon: Addon) => {
|
||||
const label = getAddonLabel(t, addon)
|
||||
return (
|
||||
<AddonTile
|
||||
key={addon.id}
|
||||
icon={<AddonIcon name={addon.icon} size={18} enabled={addon.enabled} />}
|
||||
name={label.name}
|
||||
description={label.description}
|
||||
enabled={addon.enabled}
|
||||
onToggle={() => handleToggle(addon)}
|
||||
>
|
||||
{shelfFor(addon)}
|
||||
</AddonTile>
|
||||
)
|
||||
}
|
||||
|
||||
/* One column per type, side by side, instead of three stacked sections. Stacked, each
|
||||
section opened its own set of columns and the tall tiles (Collab 261px, Journey
|
||||
185px) set a section's height while its neighbours ran out early. Side by side the
|
||||
column heading carries the type, so the tiles need no badge of their own. */
|
||||
const groups = [
|
||||
{ key: 'trip', addons: tripAddons },
|
||||
{ key: 'global', addons: globalAddons },
|
||||
{ key: 'integration', addons: integrationAddons },
|
||||
].filter(g => g.addons.length > 0)
|
||||
const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length
|
||||
const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||
<div>
|
||||
<h2 className="text-subtitle font-semibold tracking-tight text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-1 text-caption text-content-muted">
|
||||
{t('admin.addons.subtitleBefore')}
|
||||
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, verticalAlign: 'middle', opacity: 0.7 }} />
|
||||
{t('admin.addons.subtitleAfter')}
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-6 py-4 border-b border-edge-secondary">
|
||||
<h2 className="font-semibold text-content">{t('admin.addons.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted" style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||
</p>
|
||||
</div>
|
||||
{totalCount > 0 && (
|
||||
<span
|
||||
className="shrink-0 text-caption tabular-nums text-content-faint"
|
||||
title={t('admin.addons.group.count', { enabled: enabledCount, total: totalCount })}
|
||||
>
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<EmptyState scene="idle" title={t('admin.addons.noAddons')} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{groups.map(g => {
|
||||
const meta = TYPE_META[g.key]
|
||||
return (
|
||||
<section key={g.key}>
|
||||
<GroupHead icon={meta.icon} label={t(meta.labelKey)} hint={t(meta.hintKey)} addons={g.addons} t={t} />
|
||||
<div className="space-y-3">{g.addons.map(tile)}</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupHead({ icon: Icon, label, hint, addons, t }: {
|
||||
icon: ComponentType<{ size?: number; className?: string }>
|
||||
label: string
|
||||
hint: string
|
||||
addons: Addon[]
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}) {
|
||||
const enabled = addons.filter(a => a.enabled).length
|
||||
return (
|
||||
<div
|
||||
className="mb-3 rounded-xl border border-edge-secondary bg-surface-secondary px-3 py-2"
|
||||
title={t('admin.addons.group.count', { enabled, total: addons.length })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={13} className="shrink-0 text-content-muted" />
|
||||
<h3 className="min-w-0 flex-1 truncate text-caption font-semibold uppercase tracking-[0.06em] text-content">{label}</h3>
|
||||
<span className="shrink-0 rounded-full bg-surface px-1.5 py-0.5 text-caption font-medium tabular-nums text-content-muted">
|
||||
{enabled}/{addons.length}
|
||||
</span>
|
||||
</div>
|
||||
{/* Says what the type means — the only place that still explains it. */}
|
||||
<p className="mt-0.5 text-caption text-content-faint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MASKED = '••••••••'
|
||||
const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1'
|
||||
|
||||
/** Curated models the local extractor is tuned for, pullable via Ollama. The router drives
|
||||
* one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled
|
||||
* automatically, so the Qwen3 family works without any tuning. A host only needs one. */
|
||||
const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [
|
||||
{ id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true },
|
||||
]
|
||||
|
||||
/**
|
||||
* Instance-wide AI-parsing config. When set, applies to the whole instance and
|
||||
* overrides per-user config (see server llmConfig.ts). The API key is masked on
|
||||
* read; an unchanged mask is treated as a no-op by the server. For the local
|
||||
* provider, it also lists installed Ollama models and can pull NuExtract models.
|
||||
*/
|
||||
function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
const toast = useToast()
|
||||
const cfg = (addon.config ?? {}) as Record<string, unknown>
|
||||
const [provider, setProvider] = useState<string>((cfg.provider as string) ?? 'local')
|
||||
const [model, setModel] = useState<string>((cfg.model as string) ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState<string>((cfg.baseUrl as string) ?? '')
|
||||
const [apiKey, setApiKey] = useState<string>((cfg.apiKey as string) ?? '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Local-provider model management.
|
||||
const [installed, setInstalled] = useState<string[]>([])
|
||||
const [modelsErr, setModelsErr] = useState('')
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [pulling, setPulling] = useState<string | null>(null)
|
||||
const [pullPct, setPullPct] = useState(0)
|
||||
const [pullStatus, setPullStatus] = useState('')
|
||||
|
||||
const effectiveUrl = baseUrl.trim() || DEFAULT_OLLAMA_URL
|
||||
const isInstalled = (id: string) => installed.some(n => n === id || n.startsWith(id + ':') || n.startsWith(id))
|
||||
|
||||
const loadModels = async () => {
|
||||
if (provider !== 'local') return
|
||||
setLoadingModels(true)
|
||||
setModelsErr('')
|
||||
try {
|
||||
const res = await adminApi.llmLocalModels(effectiveUrl)
|
||||
setInstalled(res.models.map(m => m.name))
|
||||
} catch (e: unknown) {
|
||||
setModelsErr(e instanceof Error ? e.message : 'Could not reach the local LLM server')
|
||||
setInstalled([])
|
||||
} finally {
|
||||
setLoadingModels(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load installed models when the local provider is active.
|
||||
useEffect(() => {
|
||||
if (provider === 'local') loadModels()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [provider])
|
||||
|
||||
const pull = async (id: string) => {
|
||||
if (pulling) return
|
||||
setPulling(id)
|
||||
setPullPct(0)
|
||||
setPullStatus('starting…')
|
||||
try {
|
||||
await adminApi.llmLocalPull(effectiveUrl, id, (p) => {
|
||||
if (p.error) throw new Error(p.error)
|
||||
if (p.status) setPullStatus(p.status)
|
||||
if (p.total && p.completed != null) setPullPct(Math.round((p.completed / p.total) * 100))
|
||||
})
|
||||
toast.success('Model pulled')
|
||||
setModel(id)
|
||||
await loadModels()
|
||||
} catch (e: unknown) {
|
||||
toast.error(e instanceof Error ? e.message : 'Pull failed')
|
||||
} finally {
|
||||
setPulling(null)
|
||||
setPullPct(0)
|
||||
setPullStatus('')
|
||||
}
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
// Send the masked sentinel unchanged so the server keeps the stored key.
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none'
|
||||
const labelCls = 'mb-1 block text-caption font-medium text-content-secondary'
|
||||
|
||||
const providerOptions = [
|
||||
{ value: 'local', label: 'Local · OpenAI-compatible', icon: <Server size={14} />, badge: 'Ollama' },
|
||||
{ value: 'openai', label: 'OpenAI', icon: <Cloud size={14} /> },
|
||||
{ value: 'anthropic', label: 'Anthropic', icon: <Sparkles size={14} /> },
|
||||
]
|
||||
|
||||
/* Lives in the tile's shelf like the collab toggles, so everything is caption-
|
||||
sized and single-column — the band this used to be had a whole page width. */
|
||||
return (
|
||||
<li className="space-y-2.5 py-1.5">
|
||||
<p className="text-caption text-content-faint">
|
||||
Instance-wide — applies to all users. Leave blank to let each user configure their own provider.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<span className={labelCls}>Provider</span>
|
||||
<CustomSelect value={provider} onChange={v => setProvider(String(v))} options={providerOptions} />
|
||||
</div>
|
||||
{provider !== 'anthropic' && (
|
||||
<label className="block">
|
||||
<span className={labelCls}>Base URL</span>
|
||||
<input type="url" autoComplete="off" className={fieldCls} value={baseUrl} onChange={e => setBaseUrl(e.target.value)} onBlur={loadModels} placeholder={provider === 'local' ? 'http://localhost:11434/v1' : 'https://api.openai.com/v1'} />
|
||||
</label>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>API key</span>
|
||||
<input type="password" className={fieldCls} value={apiKey} onChange={e => setApiKey(e.target.value)} placeholder={apiKey === MASKED ? MASKED : provider === 'local' ? '(often not required)' : 'sk-…'} />
|
||||
</label>
|
||||
{provider === 'anthropic' && (
|
||||
<p className="text-caption text-content-faint">Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.</p>
|
||||
)}
|
||||
<label className="block">
|
||||
<span className={labelCls}>Model</span>
|
||||
<input autoComplete="off" className={fieldCls} value={model} onChange={e => setModel(e.target.value)} placeholder={provider === 'anthropic' ? 'claude-opus-4-8' : provider === 'openai' ? 'gpt-4o' : 'select or pull below'} />
|
||||
</label>
|
||||
|
||||
{/* Local model management (Ollama) */}
|
||||
{provider === 'local' && (
|
||||
<div className="space-y-2 rounded-lg border border-edge-secondary bg-surface p-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-caption font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-caption text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
{addons.length === 0 ? (
|
||||
<div className="p-8 text-center text-sm text-content-faint">
|
||||
{t('admin.addons.noAddons')}
|
||||
</div>
|
||||
{modelsErr && <p className="text-caption text-danger">{modelsErr}</p>}
|
||||
{!modelsErr && installed.length === 0 && !loadingModels && (
|
||||
<p className="text-caption text-content-faint">No models installed yet — pull one below.</p>
|
||||
)}
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
className={`max-w-full truncate rounded-full border px-2 py-0.5 text-caption transition-colors ${model === name ? 'border-transparent bg-accent text-accent-text' : 'border-edge-secondary text-content-secondary hover:border-edge'}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-edge-secondary pt-2">
|
||||
<div className="mb-1.5 text-caption font-medium text-content-secondary">Pull a recommended model</div>
|
||||
<div className="space-y-1">
|
||||
{RECOMMENDED_MODELS.map(m => {
|
||||
const installedHere = isInstalled(m.id)
|
||||
const isPulling = pulling === m.id
|
||||
const active = model === m.id
|
||||
return (
|
||||
<div key={m.id} className="min-w-0" title={m.note}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption text-content">{m.label}</span>
|
||||
{m.recommended && (
|
||||
<span className="shrink-0 rounded-md bg-success-soft px-1.5 py-px text-caption font-semibold text-success">Recommended</span>
|
||||
)}
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-2 py-1 text-caption font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
{active ? 'Selected' : 'Use'}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-2 py-1 text-caption font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPulling && (
|
||||
<div className="mt-1">
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-surface-tertiary">
|
||||
<div className="h-full bg-accent transition-[width] duration-200" style={{ width: `${pullPct}%` }} />
|
||||
) : (
|
||||
<div>
|
||||
{/* Trip Addons */}
|
||||
{tripAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Briefcase size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||
</span>
|
||||
</div>
|
||||
{tripAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||
<div className="flex items-center gap-4 px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<Luggage size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t('admin.bagTracking.title')}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t('admin.bagTracking.subtitle')}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${bagTrackingEnabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={onToggleBagTracking}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{COLLAB_SUB_FEATURES.map(feat => {
|
||||
const enabled = collabFeatures[feat.key]
|
||||
const Icon = feat.icon
|
||||
return (
|
||||
<div key={feat.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
<Icon size={14} className="text-content-faint" style={{ flexShrink: 0 }} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{t(feat.titleKey)}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{t(feat.subtitleKey)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button onClick={() => onToggleCollabFeature(feat.key)}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-0.5 text-caption text-content-faint">{pullStatus}{pullPct ? ` · ${pullPct}%` : ''}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-3 py-1.5 text-caption font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{/* Global Addons */}
|
||||
{globalAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Globe size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||
</span>
|
||||
</div>
|
||||
{globalAddons.map(addon => (
|
||||
<div key={addon.id}>
|
||||
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||
{/* Memories providers as sub-items under Journey addon */}
|
||||
{addon.id === 'journey' && providerOptions.length > 0 && (
|
||||
<div className="px-6 py-3 border-b border-edge-secondary bg-surface-secondary" style={{ paddingLeft: 70 }}>
|
||||
<div className="space-y-2">
|
||||
{providerOptions.map(provider => {
|
||||
const ProviderIcon = PROVIDER_ICONS[provider.key]
|
||||
return (
|
||||
<div key={provider.key} className="flex items-center gap-4" style={{ minHeight: 32 }}>
|
||||
{ProviderIcon && <span className="text-content-faint"><ProviderIcon size={14} /></span>}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-sm font-medium text-content-secondary">{provider.label}</div>
|
||||
<div className="text-xs mt-0.5 text-content-faint">{provider.description}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${provider.enabled ? 'text-content' : 'text-content-faint'}`}>
|
||||
{provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={provider.toggle}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: provider.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
>
|
||||
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||
style={{ transform: provider.enabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Integration Addons */}
|
||||
{integrationAddons.length > 0 && (
|
||||
<div>
|
||||
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2 bg-surface-secondary border-edge-secondary">
|
||||
<Link2 size={13} className="text-content-muted" />
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-content-muted">
|
||||
{t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')}
|
||||
</span>
|
||||
</div>
|
||||
{integrationAddons.map(addon => (
|
||||
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addon: Addon) => void
|
||||
t: (key: string) => string
|
||||
statusOverride?: boolean
|
||||
hideToggle?: boolean
|
||||
}
|
||||
|
||||
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||
@@ -575,3 +329,56 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string
|
||||
}
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) {
|
||||
const isComingSoon = false
|
||||
const label = getAddonLabel(t, addon)
|
||||
const displayName = nameOverride || label.name
|
||||
const displayDescription = descriptionOverride || label.description
|
||||
const enabledState = statusOverride ?? addon.enabled
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95 border-edge-secondary" style={{ opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 bg-surface-secondary text-content">
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{displayName}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full text-content-faint bg-surface-tertiary">
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-surface-secondary text-content-muted">
|
||||
{addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mt-0.5 text-content-muted">{displayDescription}</p>
|
||||
</div>
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={`hidden sm:inline text-xs font-medium ${(enabledState && !isComingSoon) ? 'text-content' : 'text-content-faint'}`}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: (enabledState && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform bg-surface-card"
|
||||
style={{
|
||||
transform: (enabledState && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under
|
||||
* Lists, a collab feature under Collab, a photo provider under Journey.
|
||||
*
|
||||
* A photo provider without a vendor mark has to stay free of `<svg>`
|
||||
* (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer
|
||||
* rather than a placeholder glyph. Nothing else belongs in this row either: no
|
||||
* chevron, no status dot, no lucide fallback.
|
||||
*
|
||||
* The row carries its title only; the description rides along as the `title`
|
||||
* tooltip, because a second line of text is what makes a child weigh as much as
|
||||
* an addon.
|
||||
*/
|
||||
export default function AddonSubRow({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
/** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
return (
|
||||
<li className="flex min-h-[36px] items-center gap-3">
|
||||
{icon ? <span className="shrink-0 text-content-faint">{icon}</span> : <span className="w-3.5 shrink-0" aria-hidden />}
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-medium text-content-secondary" title={description}>
|
||||
{title}
|
||||
</span>
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={title} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
||||
|
||||
/**
|
||||
* One addon in the admin grid: a card whose fill carries the on/off state, with
|
||||
* an optional shelf at the foot holding its sub-features.
|
||||
*
|
||||
* The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it
|
||||
* sits on) — that pair reads in both themes, because secondary is *darker* than
|
||||
* the card in light and *lighter* in dark. The obvious alternative, on =
|
||||
* `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud
|
||||
* ones.
|
||||
*
|
||||
* Hover and focus ride on border and fill rather than a shadow: `--shadow-card`
|
||||
* is only declared in `:root`, so the `.dark` palette never overrides it and a
|
||||
* shadow-based affordance would be invisible there.
|
||||
*
|
||||
* The card is not a click target — only the switches are. No lift, no pointer
|
||||
* cursor, no `role="button"`.
|
||||
*/
|
||||
export default function AddonTile({
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
enabled,
|
||||
onToggle,
|
||||
children,
|
||||
}: {
|
||||
icon: ReactNode
|
||||
name: string
|
||||
description?: string
|
||||
enabled: boolean
|
||||
onToggle: () => void
|
||||
/** The sub-shelf: `<AddonSubRow>` children. */
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col overflow-hidden rounded-xl border transition-[background-color,border-color] duration-150 focus-within:ring-2 focus-within:ring-accent ${
|
||||
enabled
|
||||
? 'border-edge bg-surface-secondary'
|
||||
: 'border-edge-secondary bg-surface hover:border-edge hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<div
|
||||
// overflow-hidden so a brand mark that fills the slot keeps the slot's
|
||||
// rounded corners instead of squaring them off.
|
||||
className={`grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-xl ${
|
||||
enabled ? 'border border-accent bg-accent-subtle text-accent-on' : 'bg-surface-tertiary text-content-faint'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* The name stays full-strength when off: it is the tile's identity, not its
|
||||
state. Greying it out reads as "broken" rather than "not enabled". */}
|
||||
<h4 className="truncate text-body font-semibold text-content" title={name}>
|
||||
{name}
|
||||
</h4>
|
||||
<p
|
||||
className={`mt-0.5 line-clamp-2 text-caption ${enabled ? 'text-content-muted' : 'text-content-faint'}`}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleSwitch on={enabled} onToggle={onToggle} label={name} />
|
||||
</div>
|
||||
|
||||
{children && (
|
||||
<div className="border-t border-edge-secondary bg-surface-tertiary px-4 py-2.5">
|
||||
{/* Sitting inside the parent on its own fill is enough to say these belong
|
||||
to it — no rail, no indent guides. */}
|
||||
<ul className="space-y-0.5">{children}</ul>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
|
||||
it('FE-ADMIN-MCP-002: empty state rendered when no tokens', async () => {
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(await screen.findByText('No MCP tokens have been created yet')).toBeInTheDocument();
|
||||
await screen.findByText('No MCP tokens have been created yet');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-003: token list renders correctly', async () => {
|
||||
@@ -195,7 +195,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<><ToastContainer /><AdminMcpTokensPanel /></>);
|
||||
expect(await screen.findByText('Failed to load tokens')).toBeInTheDocument();
|
||||
await screen.findByText('Failed to load tokens');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-011: OAuth sessions loading spinner shown on mount', async () => {
|
||||
@@ -216,7 +216,7 @@ describe('AdminMcpTokensPanel', () => {
|
||||
)
|
||||
);
|
||||
render(<AdminMcpTokensPanel />);
|
||||
expect(await screen.findByText('No active OAuth sessions')).toBeInTheDocument();
|
||||
await screen.findByText('No active OAuth sessions');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-MCP-013: OAuth sessions list renders with scopes', async () => {
|
||||
@@ -318,6 +318,6 @@ describe('AdminMcpTokensPanel', () => {
|
||||
const deleteBtns = screen.getAllByRole('button', { name: 'Delete' });
|
||||
const confirmBtn = deleteBtns.find(b => !b.title);
|
||||
await user.click(confirmBtn ?? deleteBtns[deleteBtns.length - 1]);
|
||||
expect(await screen.findByText('Failed to revoke session')).toBeInTheDocument();
|
||||
await screen.findByText('Failed to revoke session');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,13 +126,13 @@ export default function AdminMcpTokensPanel() {
|
||||
</span>
|
||||
))}
|
||||
{!expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
+{hidden} more
|
||||
</button>
|
||||
)}
|
||||
{expanded && hidden > 0 && (
|
||||
<button type="button" onClick={() => toggleScopes(session.id)}
|
||||
<button onClick={() => toggleScopes(session.id)}
|
||||
className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium transition-colors hover:opacity-80 bg-surface-secondary text-content-secondary border border-edge">
|
||||
show less
|
||||
</button>
|
||||
@@ -146,7 +146,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right pt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{new Date(session.created_at).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<button type="button" onClick={() => setRevokeConfirmId(session.id)}
|
||||
<button onClick={() => setRevokeConfirmId(session.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -199,7 +199,7 @@ export default function AdminMcpTokensPanel() {
|
||||
<span className="text-xs whitespace-nowrap text-right" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleDateString(locale) : t('admin.mcpTokens.never')}
|
||||
</span>
|
||||
<button type="button" onClick={() => setDeleteConfirmId(token.id)}
|
||||
<button onClick={() => setDeleteConfirmId(token.id)}
|
||||
className="p-1.5 rounded-lg transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
style={{ color: 'var(--text-tertiary)' }} title={t('common.delete')}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
@@ -214,21 +214,16 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Revoke OAuth session modal */}
|
||||
{revokeConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
role="button" tabIndex={0} aria-label={t('common.close')}
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}
|
||||
onKeyDown={e => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRevokeConfirmId(null) }
|
||||
}}>
|
||||
onClick={e => { if (e.target === e.currentTarget) setRevokeConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.oauthSessions.revokeTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.oauthSessions.revokeMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setRevokeConfirmId(null)}
|
||||
<button onClick={() => setRevokeConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleRevoke(revokeConfirmId)}
|
||||
<button onClick={() => handleRevoke(revokeConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
@@ -240,21 +235,16 @@ export default function AdminMcpTokensPanel() {
|
||||
{/* Delete MCP token modal */}
|
||||
{deleteConfirmId !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-[rgba(0,0,0,0.5)]"
|
||||
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) }
|
||||
}}>
|
||||
onClick={e => { if (e.target === e.currentTarget) setDeleteConfirmId(null) }}>
|
||||
<div className="rounded-xl shadow-xl w-full max-w-sm p-6 space-y-4 bg-surface-card">
|
||||
<h3 className="text-base font-semibold text-content">{t('admin.mcpTokens.deleteTitle')}</h3>
|
||||
<p className="text-sm text-content-secondary">{t('admin.mcpTokens.deleteMessage')}</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setDeleteConfirmId(null)}
|
||||
<button onClick={() => setDeleteConfirmId(null)}
|
||||
className="px-4 py-2 rounded-lg text-sm border border-edge text-content-secondary">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(deleteConfirmId)}
|
||||
<button onClick={() => handleDelete(deleteConfirmId)}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700">
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user