mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bc442f75b8 | |||
| 989a019cb3 | |||
| 994a438cfd | |||
| 4ad8146dbf | |||
| 30d09a019c | |||
| 6584696894 | |||
| 512fc51f3e | |||
| ffa1af61b7 | |||
| 60413578fa | |||
| b98f2a9afd | |||
| 2ca16caf7c | |||
| 8a0bd61fc4 | |||
| ac201deb35 | |||
| a4972f8f13 | |||
| c9627af239 | |||
| 2899465dac | |||
| a87b656b43 |
+1
-2
@@ -15,8 +15,6 @@ uploads
|
||||
chart/
|
||||
docs/
|
||||
docker-compose.yml
|
||||
docker-compose.minio-test.yml
|
||||
minio-licence.key
|
||||
unraid-template.xml
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
@@ -34,5 +32,6 @@ server/tests/
|
||||
server/vitest.config.ts
|
||||
**/*.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
|
||||
|
||||
@@ -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,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale invalid-title issues
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
close-stale:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close stale wrong-base-branch PRs
|
||||
|
||||
@@ -9,7 +9,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
check-title:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Flag or redirect issue
|
||||
@@ -77,7 +77,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,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.bump.outputs.VERSION }}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
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'
|
||||
- 'plugin-sdk/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump:
|
||||
@@ -22,22 +33,16 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
version-bump:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/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 +114,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:
|
||||
@@ -214,4 +219,3 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
charts_url: https://chart.liketrek.com
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check-target:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -68,38 +68,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
|
||||
|
||||
@@ -14,7 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -9,15 +9,11 @@ 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
|
||||
if: github.repository == 'mauriceboe/TREK' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+52
-400
@@ -1,115 +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'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
- '!**/*.md'
|
||||
pull_request:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'plugin-sdk/**'
|
||||
# npm workspaces hoist every dependency into the single root lockfile,
|
||||
# so a dependency bump touches none of the four directories above — and
|
||||
# a native binding like better-sqlite3 is exactly what needs the suite.
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'sonar-project.properties'
|
||||
- '.github/workflows/test.yml'
|
||||
# Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect
|
||||
# neither the tests nor the scan — no test reads checked-in markdown
|
||||
# and SonarCloud has no markdown analyzer. Negations must stay last.
|
||||
- '!**/*.md'
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push to the same branch/PR supersedes the running one — cancel it
|
||||
# rather than spending the coverage fan-out on a stale commit. Safe here
|
||||
# because the Sonar scan job runs last and only the latest commit matters.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
plugin-facts:
|
||||
name: Plugin Permission Facts
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# 22 matches the TREK server runtime (same choice as the plugin-sdk
|
||||
# jobs and publish-plugin-sdk.yml).
|
||||
node-version: 22
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace server
|
||||
|
||||
# The plugin permission facts live once, in
|
||||
# server/src/nest/plugins/protocol/envelope.ts. Every other copy — the
|
||||
# SDK's generated/host-facts.ts and shared's plugin-permissions.ts — is
|
||||
# generated from it and checked in, because publish-plugin-sdk builds
|
||||
# from a standalone checkout with no server present. This fails the run
|
||||
# if a copy drifts, if a new permission has no admin.plugins.perm.*
|
||||
# translation, or if the CLI has no hint for it.
|
||||
- name: Check the generated plugin facts are in sync
|
||||
run: cd server && npm run check:plugin-facts
|
||||
|
||||
i18n-parity:
|
||||
name: i18n Key Parity
|
||||
runs-on: ubuntu-latest
|
||||
@@ -124,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
|
||||
|
||||
@@ -139,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:
|
||||
@@ -199,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,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
if: github.repository == 'mauriceboe/TREK'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
+2
-4
@@ -51,7 +51,6 @@ yarn-error.log*
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
@@ -65,8 +64,7 @@ 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
|
||||
# Wiki offline snapshot is baked in at build, not committed (duplicates wiki/)
|
||||
server/assets/wiki/
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
|
||||
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
|
||||
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
|
||||
|
||||
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the repo root unless noted. **Use `npm run ... --workspace=<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
|
||||
npm run format # prettier --write all three
|
||||
```
|
||||
|
||||
Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server)
|
||||
npm run test:unit --workspace=server # tests/unit only
|
||||
npm run test:integration --workspace=server
|
||||
npm run test:ws --workspace=server # websocket tests
|
||||
npm run test:e2e # server e2e (boots Nest against temp SQLite)
|
||||
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright
|
||||
cd client && npm run lint:pages # enforce the Page pattern (see below)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
|
||||
```
|
||||
|
||||
Run a single test file or test:
|
||||
|
||||
```bash
|
||||
npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or client/)
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
```
|
||||
|
||||
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
|
||||
|
||||
```bash
|
||||
npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
|
||||
|
||||
## Server architecture
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
|
||||
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
|
||||
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
|
||||
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
|
||||
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
|
||||
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
|
||||
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
|
||||
|
||||
## Client architecture
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
|
||||
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
|
||||
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
|
||||
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
|
||||
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
|
||||
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
|
||||
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
|
||||
+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.
|
||||
|
||||
+11
-29
@@ -46,11 +46,6 @@ COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# The trailing chown runs in this layer on purpose: it covers the manifests and
|
||||
# the freshly installed node_modules while they are already part of this layer's
|
||||
# changeset, so it costs nothing. Everything copied after this point carries
|
||||
# --chown=node:node for the same reason — a recursive chown in a later layer
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
@@ -58,8 +53,7 @@ RUN apt-get update && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
@@ -71,42 +65,30 @@ 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/
|
||||
COPY 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
|
||||
COPY 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/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
# 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}
|
||||
|
||||
@@ -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,80 @@ 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 · day plan & route" 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="Costs · expense splitting" 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/collections.png"><img src="docs/screenshots/collections.png" alt="Collections · saved place lists" 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)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
- **Category filter** — show only matching pins on the map
|
||||
|
||||
</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))
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
- **PDF export** — full trip plan as PDF with cover page, images, notes
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -107,71 +104,64 @@ 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
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **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
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</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
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
@@ -181,26 +171,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 +183,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>
|
||||
|
||||
@@ -241,29 +211,76 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
|
||||
<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 the server 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 +296,41 @@ 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.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<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 +368,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 +390,61 @@ 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`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
| `OIDC_ISSUER` | OpenID Connect provider URL | — |
|
||||
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||
| `OIDC_DISPLAY_NAME` | Label shown on the SSO login button | `SSO` |
|
||||
| `OIDC_ONLY` | Force SSO-only mode: disables password login + registration, regardless of Admin > Settings. The first SSO login becomes admin. | `false` |
|
||||
| `OIDC_ADMIN_CLAIM` | OIDC claim used to identify admin users | — |
|
||||
| `OIDC_ADMIN_VALUE` | Value of the OIDC claim that grants admin role | — |
|
||||
| `OIDC_SCOPE` | Space-separated OIDC scopes. **Fully replaces** the default — always include `openid email profile`. | `openid email profile` |
|
||||
| `OIDC_DISCOVERY_URL` | Override the auto-constructed OIDC discovery endpoint (e.g. Authentik: `.../application/o/trek/.well-known/openid-configuration`) | — |
|
||||
| **Initial setup** | | |
|
||||
| `ADMIN_EMAIL` | Email for the first admin on initial boot. Must be set together with `ADMIN_PASSWORD`. If either is omitted a random password is printed to the server log. No effect once a user exists. | `admin@trek.local` |
|
||||
| `ADMIN_PASSWORD` | Password for the first admin on initial boot. Pairs with `ADMIN_EMAIL`. | random |
|
||||
| **Other** | | |
|
||||
| `DEMO_MODE` | Enable demo mode (hourly data resets) | `false` |
|
||||
| `UNSPLASH_ACCESS_KEY` | Optional Unsplash Access Key for trip-cover and place-image search. Without one, TREK uses Unsplash's unauthenticated endpoint, which some datacenter/VPS IPs are blocked from. Get a free key at [unsplash.com/developers](https://unsplash.com/developers). Overrides any per-admin key set in Admin > Settings (where it can also be configured instead). | — |
|
||||
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
|
||||
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `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 +453,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"
|
||||
+3
-3
@@ -1,9 +1,9 @@
|
||||
<?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>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
|
||||
+1
-3
@@ -15,13 +15,11 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
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:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 4.0.0
|
||||
version: 3.3.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "4.0.0"
|
||||
appVersion: "3.3.0"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -24,12 +24,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.
|
||||
|
||||
@@ -3,6 +3,3 @@ e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint .
|
||||
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
|
||||
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
|
||||
npm run test:unit # tests/unit
|
||||
npm run test:integration # tests/integration + co-located src tests
|
||||
npm run test:coverage # v8 coverage
|
||||
npm run e2e # Playwright
|
||||
npm run e2e:report # open last Playwright report
|
||||
npm run shots # Playwright screenshots project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
|
||||
```
|
||||
|
||||
Single test:
|
||||
|
||||
```bash
|
||||
npx vitest run src/store/slices/budgetSlice.test.ts
|
||||
npx vitest run -t "optimistically adds the place"
|
||||
npx vitest watch src/pages/DashboardPage.test.tsx
|
||||
```
|
||||
|
||||
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
|
||||
|
||||
## Page pattern (enforced — `lint:pages` will fail otherwise)
|
||||
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
|
||||
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
|
||||
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
|
||||
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
|
||||
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
|
||||
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
|
||||
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
|
||||
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
|
||||
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
|
||||
|
||||
## Other big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
|
||||
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
|
||||
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
|
||||
|
||||
## Tests
|
||||
|
||||
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
|
||||
@@ -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,79 +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 })
|
||||
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 two-pane layout — isMobile stayed width-based.
|
||||
await expect(page.locator('.leaflet-container')).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>.',
|
||||
}],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
+4
-1
@@ -19,7 +19,10 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icons/icon.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" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+7
-15
@@ -1,13 +1,12 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "4.0.0",
|
||||
"version": "3.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -18,25 +17,17 @@
|
||||
"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\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/bebas-neue": "^5.3.0",
|
||||
"@fontsource/eb-garamond": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.2.5",
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@fontsource/lora": "^5.3.0",
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"axios": "^1.6.7",
|
||||
@@ -56,14 +47,15 @@
|
||||
"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",
|
||||
@@ -76,6 +68,7 @@
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"autoprefixer": "^10.4.18",
|
||||
@@ -91,8 +84,7 @@
|
||||
"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.0",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
|
||||
@@ -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,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()}`)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-260
@@ -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() })
|
||||
@@ -523,56 +320,3 @@ describe('Version cache-busting', () => {
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: a device that has never mirrored the preference must not treat
|
||||
// "nothing mirrored" as "user wants the dashboard".
|
||||
describe('RootRedirect — preference not mirrored on this device', () => {
|
||||
it('FE-COMP-APP-031: honours the server setting when localStorage is empty', async () => {
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
// Cold start: settings are still in flight when RootRedirect first runs.
|
||||
useSettingsStore.setState({ isLoaded: false })
|
||||
server.use(http.get('/api/trips/active', () => HttpResponse.json({ trip: { id: 42, title: 'Japan' } })))
|
||||
|
||||
renderApp('/')
|
||||
|
||||
// ...and land a moment later, exactly as loadSettings() does.
|
||||
await act(async () => {
|
||||
useSettingsStore.setState({
|
||||
isLoaded: true,
|
||||
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'finanzplan' }),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect(screen.getByText('TripPlanner')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// Waiting for someone else's effect to fetch the settings made the decision
|
||||
// ride on where that request landed in the queue — behind ~380 others on a
|
||||
// cold start, which is how the redirect silently lost the race and fell
|
||||
// through to the dashboard. It now asks for them itself.
|
||||
it('FE-COMP-APP-033: asks for the settings itself instead of waiting to be told', async () => {
|
||||
const loadSettings = vi.fn().mockResolvedValue(undefined)
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ isLoaded: false, loadSettings })
|
||||
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(loadSettings).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
// loadSettings leaves isLoaded false on a failed request so it can retry, so
|
||||
// the wait above needs a floor or a launch with no backend never resolves.
|
||||
it('FE-COMP-APP-032: gives up on the settings after the timeout and opens the dashboard', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
seedAuth({ isAuthenticated: true, user: buildUser() })
|
||||
useSettingsStore.setState({ isLoaded: false })
|
||||
|
||||
renderApp('/')
|
||||
expect(screen.queryByText('Dashboard')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => { vi.advanceTimersByTime(SETTINGS_WAIT_MS + 100) })
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Dashboard')).toBeInTheDocument())
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
|
||||
+177
-373
@@ -1,27 +1,36 @@
|
||||
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 PluginPage from './pages/PluginPage'
|
||||
import LoginPage from './pages/LoginPage'
|
||||
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import TripPlannerPage from './pages/TripPlannerPage'
|
||||
import FilesPage from './pages/FilesPage'
|
||||
import AdminPage from './pages/AdminPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import VacayPage from './pages/VacayPage'
|
||||
import HelpPage from './pages/HelpPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import JourneyPage from './pages/JourneyPage'
|
||||
import JourneyDetailPage from './pages/JourneyDetailPage'
|
||||
import CollectionsPage from './pages/CollectionsPage'
|
||||
import JourneyPublicPage from './pages/JourneyPublicPage'
|
||||
import SharedTripPage from './pages/SharedTripPage'
|
||||
import JoinTripPage from './pages/JoinTripPage'
|
||||
import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
||||
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import 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 } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts'
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers'
|
||||
@@ -29,50 +38,6 @@ 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
|
||||
@@ -85,11 +50,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 (
|
||||
@@ -103,10 +66,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 />
|
||||
const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash)
|
||||
return <Navigate to={`/login?redirect=${redirectParam}`} replace />
|
||||
}
|
||||
@@ -128,132 +87,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-dvh 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>
|
||||
@@ -261,29 +106,11 @@ 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, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = 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()
|
||||
@@ -300,8 +127,7 @@ 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; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<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)
|
||||
@@ -313,7 +139,6 @@ export default function App() {
|
||||
if (config?.places_photos_enabled !== undefined) setPlacesPhotosEnabled(config.places_photos_enabled)
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
@@ -375,7 +200,6 @@ export default function App() {
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
@@ -383,173 +207,153 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!isAuthPage && (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 />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
{!isAuthPage && <SaveToCollectionModal />}
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
{/* 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>
|
||||
<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="/plugins/:pluginId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<PluginPage />
|
||||
</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="/collections"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/collections/:id"
|
||||
element={
|
||||
<ProtectedRoute addonId="collections">
|
||||
<CollectionsPage />
|
||||
</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,511 +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 unregisters the service worker and reloads', async () => {
|
||||
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-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-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,865 +0,0 @@
|
||||
// FE-APISURF-001 to FE-APISURF-054
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import {
|
||||
apiClient,
|
||||
authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi,
|
||||
tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi,
|
||||
mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi,
|
||||
configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi,
|
||||
shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi,
|
||||
} from './client'
|
||||
|
||||
interface Recorded { method: string; url: string; body: unknown }
|
||||
|
||||
let log: Recorded[] = []
|
||||
|
||||
/** One record per outgoing request: verb, path+query and (parsed) JSON body. */
|
||||
function recorder() {
|
||||
return http.all(/\/api\//, async ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const raw = await request.text()
|
||||
let body: unknown
|
||||
if (raw) {
|
||||
try { body = JSON.parse(raw) } catch { body = raw }
|
||||
}
|
||||
log.push({ method: request.method, url: url.pathname + url.search, body })
|
||||
return HttpResponse.json({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
log = []
|
||||
server.use(recorder())
|
||||
// parseInDev/checkInDev warn on every stub payload that doesn't match its
|
||||
// @trek/shared schema — expected here, so keep the output readable.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
interface Call { n: string; r: () => Promise<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: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' },
|
||||
{ n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' },
|
||||
{ n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' },
|
||||
{ n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' },
|
||||
{ n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' },
|
||||
{ n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' },
|
||||
{ n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' },
|
||||
{ n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' },
|
||||
{ n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' },
|
||||
{ n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' },
|
||||
{ n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' },
|
||||
{ n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' },
|
||||
{ n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' },
|
||||
{ n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' },
|
||||
{ n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' },
|
||||
{ n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' },
|
||||
{ n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' },
|
||||
{ n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' },
|
||||
{ n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' },
|
||||
{ n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' },
|
||||
{ n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' },
|
||||
{ n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' },
|
||||
{ n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' },
|
||||
{ n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' },
|
||||
{ n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' },
|
||||
{ n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' },
|
||||
{ n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' },
|
||||
{ n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' },
|
||||
{ n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' },
|
||||
{ n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' },
|
||||
{ n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' },
|
||||
{ n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' },
|
||||
{ n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' },
|
||||
{ n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' },
|
||||
{ n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' },
|
||||
{ n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' },
|
||||
{ n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' },
|
||||
{ n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' },
|
||||
{ n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' },
|
||||
{ n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' },
|
||||
{ n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' },
|
||||
{ n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' },
|
||||
{ n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' },
|
||||
{ n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' },
|
||||
{ n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' },
|
||||
{ n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' },
|
||||
{ n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' },
|
||||
{ n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' },
|
||||
{ n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' },
|
||||
{ n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' },
|
||||
{ n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' },
|
||||
{ n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' },
|
||||
{ n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' },
|
||||
{ n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' },
|
||||
{ n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' },
|
||||
{ n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' },
|
||||
{ n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' },
|
||||
{ n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' },
|
||||
{ n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' },
|
||||
{ n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' },
|
||||
{ n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' },
|
||||
{ n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' },
|
||||
{ n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' },
|
||||
{ n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' },
|
||||
{ n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' },
|
||||
{ n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' },
|
||||
{ n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-053: memoriesApi maps the photo-provider endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'status', r: () => memoriesApi.status('immich'), e: 'GET /api/integrations/memories/immich/status' },
|
||||
{ n: 'search', r: () => memoriesApi.search('immich', { from: '2026-01-01', to: '2026-01-02', page: 1, size: 50 }), e: 'POST /api/integrations/memories/immich/search' },
|
||||
{ n: 'albums', r: () => memoriesApi.albums('immich'), e: 'GET /api/integrations/memories/immich/albums' },
|
||||
{ n: 'albumPhotos', r: () => memoriesApi.albumPhotos('immich', 'alb-1'), e: 'GET /api/integrations/memories/immich/albums/alb-1/photos' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-054: memoriesApi passes the album passphrase as a query parameter', async () => {
|
||||
const rec = await traceOne(() => memoriesApi.albumPhotos('synologyphotos', 'alb-2', 'p/w?'))
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
{ n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' },
|
||||
{ n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' },
|
||||
{ n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' },
|
||||
{ n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' },
|
||||
{ n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' },
|
||||
{ n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' },
|
||||
{ n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' },
|
||||
{ n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' },
|
||||
{ n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' },
|
||||
{ n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' },
|
||||
{ n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' },
|
||||
{ n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' },
|
||||
{ n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' },
|
||||
{ n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' },
|
||||
{ n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' },
|
||||
{ n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' },
|
||||
{ n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' },
|
||||
{ n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' },
|
||||
{ n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' },
|
||||
{ n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' },
|
||||
{ n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' },
|
||||
{ n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' },
|
||||
{ n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' },
|
||||
{ n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' },
|
||||
{ n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' },
|
||||
{ n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' },
|
||||
{ n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' },
|
||||
{ n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' },
|
||||
{ n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' },
|
||||
{ n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' },
|
||||
{ n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' },
|
||||
{ n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' },
|
||||
{ n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' },
|
||||
{ n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' },
|
||||
{ n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' },
|
||||
{ n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' },
|
||||
{ n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' },
|
||||
{ n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' },
|
||||
{ n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' },
|
||||
{ n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' },
|
||||
{ n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' },
|
||||
{ n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' },
|
||||
{ n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' },
|
||||
{ n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' },
|
||||
{ n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' },
|
||||
{ n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' },
|
||||
{ n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' },
|
||||
{ n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' },
|
||||
{ n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' },
|
||||
{ n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' },
|
||||
{ n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, '👍'), e: 'POST /api/trips/1/collab/messages/2/react' },
|
||||
{ n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' },
|
||||
{ n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' },
|
||||
{ n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' },
|
||||
{ n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' },
|
||||
{ n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' },
|
||||
{ n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' },
|
||||
{ n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' },
|
||||
{ n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' },
|
||||
{ n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' },
|
||||
{ n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' },
|
||||
{ n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' },
|
||||
{ n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' },
|
||||
{ n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' },
|
||||
{ n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' },
|
||||
{ n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' },
|
||||
{ n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' },
|
||||
{ n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' },
|
||||
{ n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' },
|
||||
{ n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' },
|
||||
{ n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' },
|
||||
{ n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' },
|
||||
{ n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' },
|
||||
{ n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' },
|
||||
{ n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' },
|
||||
{ n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' },
|
||||
{ n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' },
|
||||
{ n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' },
|
||||
{ n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' },
|
||||
{ n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' },
|
||||
{ n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' },
|
||||
{ n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' },
|
||||
{ n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' },
|
||||
{ n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' },
|
||||
{ n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' },
|
||||
{ n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' },
|
||||
{ n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' },
|
||||
{ n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' },
|
||||
{ n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' },
|
||||
{ n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' },
|
||||
{ n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' },
|
||||
{ n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' },
|
||||
{ n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('client > request payloads', () => {
|
||||
it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => {
|
||||
expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] })
|
||||
expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] })
|
||||
expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] })
|
||||
expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] })
|
||||
expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body)
|
||||
.toEqual({ orderedCategories: ['Food', 'Fun'] })
|
||||
expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-023: user-id collections are sent as user_ids', async () => {
|
||||
expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] })
|
||||
expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] })
|
||||
expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] })
|
||||
expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => {
|
||||
expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null })
|
||||
expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' })
|
||||
expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 })
|
||||
expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' })
|
||||
expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' })
|
||||
expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null })
|
||||
expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 })
|
||||
expect((await traceOne(() => collabApi.reactMessage(1, 2, '🎉'))).body).toEqual({ emoji: '🎉' })
|
||||
expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' })
|
||||
expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } })
|
||||
expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false })
|
||||
expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true })
|
||||
expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body)
|
||||
.toEqual({ permissions: { edit: 'owner' } })
|
||||
expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body)
|
||||
.toEqual({ config: { k: 'v' } })
|
||||
})
|
||||
|
||||
it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => {
|
||||
expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true })
|
||||
expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false })
|
||||
})
|
||||
|
||||
it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => {
|
||||
expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] })
|
||||
expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body)
|
||||
.toEqual({ ids: [5], category_id: null })
|
||||
})
|
||||
|
||||
it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => {
|
||||
const cleared = await traceOne(() => placesApi.rate(1, 5, null))
|
||||
expect(cleared.method).toBe('DELETE')
|
||||
expect(cleared.url).toBe('/api/trips/1/places/5/rating')
|
||||
|
||||
const set = await traceOne(() => placesApi.rate(1, 5, 4))
|
||||
expect(set.method).toBe('PUT')
|
||||
expect(set.url).toBe('/api/trips/1/places/5/rating')
|
||||
expect(set.body).toEqual({ rating: 4 })
|
||||
})
|
||||
|
||||
it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => {
|
||||
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] })
|
||||
expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] })
|
||||
expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body)
|
||||
.toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => {
|
||||
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body)
|
||||
.toEqual({ provider: 'immich', asset_ids: ['a1'] })
|
||||
expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body)
|
||||
.toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] })
|
||||
expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body)
|
||||
.toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' })
|
||||
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body)
|
||||
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' })
|
||||
expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body)
|
||||
.toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({})
|
||||
expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true })
|
||||
})
|
||||
|
||||
it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' })
|
||||
expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body)
|
||||
.toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true })
|
||||
})
|
||||
|
||||
it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => {
|
||||
expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null })
|
||||
expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 })
|
||||
})
|
||||
|
||||
it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => {
|
||||
expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({})
|
||||
expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => {
|
||||
const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2'))
|
||||
expect(rec.method).toBe('DELETE')
|
||||
expect(rec.body).toEqual({ password: 'hunter2' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('client > query parameters', () => {
|
||||
it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => {
|
||||
const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' }))
|
||||
const qs = new URLSearchParams(rec.url.split('?')[1])
|
||||
expect(qs.get('archived')).toBe('true')
|
||||
expect(qs.get('q')).toBe('rome')
|
||||
})
|
||||
|
||||
it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => {
|
||||
expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files')
|
||||
expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true')
|
||||
})
|
||||
|
||||
it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => {
|
||||
expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement')
|
||||
expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR')
|
||||
})
|
||||
|
||||
it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => {
|
||||
expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages')
|
||||
expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url)
|
||||
.toBe('/api/trips/1/collab/messages?before=2026-01-01')
|
||||
})
|
||||
|
||||
it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => {
|
||||
expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry')
|
||||
expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1')
|
||||
})
|
||||
|
||||
it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => {
|
||||
const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 }))
|
||||
expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50')
|
||||
expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100')
|
||||
|
||||
const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434'))
|
||||
expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434')
|
||||
})
|
||||
|
||||
it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => {
|
||||
const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de'))
|
||||
const qs = new URLSearchParams(rec.url.split('?')[1])
|
||||
expect(qs.get('category')).toBe('cafe')
|
||||
expect(qs.get('south')).toBe('41.8')
|
||||
expect(qs.get('west')).toBe('12.4')
|
||||
expect(qs.get('north')).toBe('42')
|
||||
expect(qs.get('east')).toBe('12.6')
|
||||
expect(qs.get('lang')).toBe('de')
|
||||
})
|
||||
|
||||
it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => {
|
||||
const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01'))
|
||||
const fq = new URLSearchParams(forecast.url.split('?')[1])
|
||||
expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01'])
|
||||
|
||||
const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de'))
|
||||
expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de')
|
||||
})
|
||||
|
||||
it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => {
|
||||
expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url)
|
||||
.toBe('/api/trip-card-contributions?tripIds=1,2,3')
|
||||
expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200')
|
||||
expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5')
|
||||
})
|
||||
|
||||
it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => {
|
||||
const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4]))
|
||||
expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet')
|
||||
expect(packing.body).toEqual({ user_ids: [4] })
|
||||
|
||||
const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5]))
|
||||
expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after')
|
||||
expect(todo.body).toEqual({ user_ids: [5] })
|
||||
})
|
||||
|
||||
it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => {
|
||||
const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2'))
|
||||
expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('client > multipart uploads', () => {
|
||||
// jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the
|
||||
// axios boundary instead (same approach as tests/integration/api/client.test.ts).
|
||||
function spyPost() {
|
||||
return vi.spyOn(apiClient, 'post')
|
||||
.mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse)
|
||||
}
|
||||
|
||||
it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => {
|
||||
const post = spyPost()
|
||||
const fd = new FormData()
|
||||
|
||||
await authApi.uploadAvatar(fd)
|
||||
await tripsApi.uploadCover(3, fd)
|
||||
await filesApi.upload(1, fd)
|
||||
await journeyApi.uploadPhotos(9, fd)
|
||||
await journeyApi.uploadGalleryPhotos(2, fd)
|
||||
await journeyApi.uploadGalleryVideo(2, fd)
|
||||
await journeyApi.uploadCover(2, fd)
|
||||
await collabApi.uploadNoteFile(1, 2, fd)
|
||||
|
||||
expect(post.mock.calls.map(c => c[0])).toEqual([
|
||||
'/auth/avatar',
|
||||
'/trips/3/cover',
|
||||
'/trips/1/files',
|
||||
'/journeys/entries/9/photos',
|
||||
'/journeys/2/gallery/photos',
|
||||
'/journeys/2/gallery/video',
|
||||
'/journeys/2/cover',
|
||||
'/trips/1/collab/notes/2/files',
|
||||
])
|
||||
for (const call of post.mock.calls) {
|
||||
expect(call[1]).toBeInstanceOf(FormData)
|
||||
expect(call[2]).toMatchObject({ timeout: 0 })
|
||||
expect((call[2] as { headers: Record<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')
|
||||
})
|
||||
})
|
||||
+38
-249
@@ -1,7 +1,5 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
@@ -9,11 +7,9 @@ import {
|
||||
channelTestResultSchema,
|
||||
mapsSearchResultSchema, mapsAutocompleteResultSchema, mapsPlaceDetailsResultSchema,
|
||||
mapsPlacePhotoResultSchema, mapsReverseResultSchema, mapsResolveUrlResultSchema,
|
||||
mapsPlaceEnrichmentResultSchema,
|
||||
type NotificationRespondRequest,
|
||||
type SettingUpsertRequest, type SettingsBulkRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
|
||||
type JourneyStats, type BookRecord, type BookSaveRequest,
|
||||
type JourneyCreateRequest, type JourneyAddTripRequest,
|
||||
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
|
||||
type JourneyShareLinkRequest,
|
||||
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
|
||||
@@ -22,7 +18,7 @@ import {
|
||||
type TripAddMemberRequest, type TripTransferOwnershipRequest,
|
||||
type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest, type ActiveTripResponse,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
@@ -30,12 +26,12 @@ import {
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
type PackingImportRequest, type PackingBagMembersRequest, type PackingUpdateBagRequest,
|
||||
type PackingCategoryAssigneesRequest, type PackingApplyTemplateRequest,
|
||||
type PackingCategoryAssigneesRequest,
|
||||
type BudgetUpdateMembersRequest, type BudgetToggleMemberPaidRequest, type BudgetReorderCategoriesRequest,
|
||||
type TodoCategoryAssigneesRequest,
|
||||
type CollabNoteCreateRequest, type CollabNoteUpdateRequest, type CollabPollCreateRequest,
|
||||
@@ -48,15 +44,9 @@ import {
|
||||
type BookingImportPreviewResponse,
|
||||
type BookingImportConfirmResponse,
|
||||
type BookingImportMode,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageConfigPut,
|
||||
type StorageTestResponse,
|
||||
type StorageUsage,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -114,9 +104,6 @@ const RATE_LIMIT_MESSAGES: Record<string, string> = {
|
||||
ko: '시도 횟수가 너무 많습니다. 잠시 후 다시 시도해 주세요.',
|
||||
uk: 'Занадто багато спроб. Спробуйте пізніше.',
|
||||
sv: 'För många försök. Prova igen senare.',
|
||||
ca: 'Massa intents. Torneu-ho a provar més tard.',
|
||||
gr: 'Πάρα πολλές προσπάθειες. Δοκιμάστε ξανά αργότερα.',
|
||||
vi: 'Quá nhiều lần thử. Vui lòng thử lại sau.',
|
||||
}
|
||||
|
||||
function translateRateLimit(): string {
|
||||
@@ -152,7 +139,10 @@ apiClient.interceptors.request.use(
|
||||
// The mutation queue sets its own pre-generated key; skip if already set.
|
||||
const method = (config.method ?? '').toLowerCase()
|
||||
if (MUTATING_METHODS.has(method) && !config.headers['X-Idempotency-Key']) {
|
||||
config.headers['X-Idempotency-Key'] = randomId()
|
||||
const key = typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: Math.random().toString(36).slice(2)
|
||||
config.headers['X-Idempotency-Key'] = key
|
||||
}
|
||||
return config
|
||||
},
|
||||
@@ -237,11 +227,9 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data
|
||||
// Only a plain object body carries an `error` field worth overwriting;
|
||||
// an array (a validation-error list) or a string is replaced outright.
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
(data as { error?: string }).error = translated
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
@@ -378,9 +366,6 @@ export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data: TripCreateRequest) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
// The startup redirect's one lookup — deliberately not list(), which would pull
|
||||
// every trip with its counts just to read one id.
|
||||
active: (): Promise<ActiveTripResponse> => apiClient.get('/trips/active').then(r => r.data),
|
||||
update: (id: number | string, data: TripUpdateRequest) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => postMultipart(`/trips/${id}/cover`, formData),
|
||||
@@ -402,8 +387,6 @@ export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
||||
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
@@ -415,15 +398,6 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
uploadImage: (tripId: number | string, id: number | string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
|
||||
},
|
||||
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
|
||||
rating === null
|
||||
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
|
||||
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
@@ -459,10 +433,6 @@ export const assignmentsApi = {
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default).
|
||||
// direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case
|
||||
// and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape.
|
||||
updateTransport: (tripId: number | string, id: number, mode: string | null, direction: 'outgoing' | 'incoming' = 'outgoing') => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, (direction === 'incoming' ? { transport_mode: mode, direction } : { transport_mode: mode }) satisfies Partial<AssignmentTransportRequest>).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
@@ -479,7 +449,7 @@ export const packingApi = {
|
||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number, visibility: 'common' | 'personal' = 'common') => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`, { visibility } satisfies PackingApplyTemplateRequest).then(r => r.data),
|
||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||
@@ -531,17 +501,7 @@ export const adminApi = {
|
||||
apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data),
|
||||
pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data),
|
||||
pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data),
|
||||
// `version` pins the exact version to install (the rollback path); omitted, the server
|
||||
// resolves the newest TREK-compatible version itself.
|
||||
pluginUpdate: (id: string, version?: string) =>
|
||||
apiClient.post(`/admin/plugins/${id}/update`, version ? { version } : {}).then(r => r.data),
|
||||
// Release a per-plugin update hold (set by a deliberate non-latest install).
|
||||
pluginResumeUpdates: (id: string) => apiClient.post(`/admin/plugins/${id}/resume-updates`).then(r => r.data),
|
||||
// Re-trust a ROTATED author signing key and update, in ONE call. `publicKey` is the
|
||||
// full key the admin was shown (not a fingerprint): the server compares it exactly, so
|
||||
// it can refuse if the registry entry was re-keyed again since the dialog rendered.
|
||||
pluginRetrust: (id: string, version: string, publicKey: string) =>
|
||||
apiClient.post(`/admin/plugins/${id}/retrust`, { version, publicKey }).then(r => r.data),
|
||||
pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data),
|
||||
pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data),
|
||||
pluginRescan: () => apiClient.post('/admin/plugins/rescan').then(r => r.data),
|
||||
pluginUpload: (file: File) => { const fd = new FormData(); fd.append('file', file); return postMultipart('/admin/plugins/upload', fd) },
|
||||
@@ -572,35 +532,24 @@ export const adminApi = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
if (!res.ok || !res.body) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
// An accepted request without a stream can't be followed to completion.
|
||||
if (!res.body) throw new Error('Pull returned no progress stream')
|
||||
const reader = res.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
// split() always yields at least one element; the last one is the
|
||||
// trailing (possibly partial) line carried into the next chunk.
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop()!
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
// Only the parse is swallowed — a throw from onProgress aborts the pull.
|
||||
let frame: { status?: string; total?: number; completed?: number; error?: string }
|
||||
try { frame = JSON.parse(line) } catch { continue }
|
||||
onProgress(frame)
|
||||
}
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {})
|
||||
}
|
||||
},
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
@@ -612,8 +561,6 @@ export const adminApi = {
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -646,26 +593,6 @@ export const adminApi = {
|
||||
updateNotificationPreferences: (prefs: Record<string, Record<string, boolean>>) => apiClient.put('/admin/notification-preferences', prefs).then(r => r.data),
|
||||
getDefaultUserSettings: () => apiClient.get('/admin/default-user-settings').then(r => r.data),
|
||||
updateDefaultUserSettings: (settings: Record<string, unknown>) => apiClient.put('/admin/default-user-settings', settings).then(r => r.data),
|
||||
getStorage: (): Promise<StorageAdminState> => apiClient.get('/admin/storage').then(r => r.data),
|
||||
updateStorage: (config: StorageConfigPut): Promise<StorageAdminState> =>
|
||||
apiClient.put('/admin/storage', config).then(r => r.data),
|
||||
// A probe is bounded by the target driver's own timeout (default 30s, ×2 with
|
||||
// one retry) and a mirror probes its targets sequentially — the instance-wide
|
||||
// 8s axios timeout would abort a legitimate slow probe, so this call carries
|
||||
// its own generous ceiling.
|
||||
testStorageBackend: (backend: StorageBackend): Promise<StorageTestResponse> =>
|
||||
apiClient.post('/admin/storage/test', { backend }, { timeout: 120_000 }).then(r => r.data),
|
||||
startStorageBackfill: (backend: string): Promise<{ started: true }> =>
|
||||
apiClient.post(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
cancelStorageBackfill: (backend: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/backends/${encodeURIComponent(backend)}/backfill`).then(r => r.data),
|
||||
startStorageMigration: (category: string, to: string): Promise<{ started: true }> =>
|
||||
apiClient.post('/admin/storage/migrations', { category, to }).then(r => r.data),
|
||||
cancelStorageMigration: (category: string): Promise<{ cancelled: true }> =>
|
||||
apiClient.delete(`/admin/storage/migrations/${encodeURIComponent(category)}`).then(r => r.data),
|
||||
// A full scan of a large install can exceed the 8s instance timeout.
|
||||
refreshStorageStats: (): Promise<StorageUsage> =>
|
||||
apiClient.post('/admin/storage/stats/refresh', undefined, { timeout: 120_000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
@@ -692,74 +619,6 @@ export interface PluginMapMarker {
|
||||
tone: 'default' | 'success' | 'warn' | 'danger'
|
||||
}
|
||||
|
||||
/** One shape of a plugin map layer (mapLayerProvider hook). Server-normalized:
|
||||
* coordinates range-checked, vertex budget capped, styling clamped to the tone
|
||||
* palette + bounded numerics — never free-form CSS or markup. */
|
||||
export interface PluginMapLayerFeature {
|
||||
type: 'polyline' | 'polygon' | 'circle';
|
||||
points?: Array<[number, number]>;
|
||||
center?: [number, number];
|
||||
radiusM?: number;
|
||||
tone: 'default' | 'success' | 'warn' | 'danger';
|
||||
width: number;
|
||||
dash: 'solid' | 'dash' | 'dot';
|
||||
opacity: number;
|
||||
fill: boolean;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A vector overlay a plugin draws on the trip map (routes, corridors, zones). */
|
||||
export interface PluginMapLayer {
|
||||
pluginId: string; id: string; name?: string;
|
||||
features: PluginMapLayerFeature[];
|
||||
}
|
||||
|
||||
/** A time contribution a dayScheduleProvider plugin attaches to the day plan
|
||||
* ("35 min charging at this stop"). Server-normalized: dayIds checked against
|
||||
* the trip, minutes clamped to a day, labels sanitized + capped. */
|
||||
export interface PluginDayScheduleItem {
|
||||
pluginId: string; id: string; dayId: number;
|
||||
assignmentId?: number; reservationId?: number;
|
||||
position?: 'start' | 'end';
|
||||
minutes?: number; label: string;
|
||||
tone: 'default' | 'success' | 'warn' | 'danger';
|
||||
}
|
||||
|
||||
/** The colours a dayTintProvider plugin puts into one day card, so leg membership is
|
||||
* visible while scrolling the itinerary. The card has three separately tintable
|
||||
* regions; an absent one is not tinted and renders exactly as it does with no plugin.
|
||||
* Server-normalized: dayIds checked against the trip, one contribution per day (first
|
||||
* granted provider wins), the `tone` / `color` shorthands already resolved into the
|
||||
* regions, labels sanitized + capped.
|
||||
*
|
||||
* A region carries EITHER a tone from the fixed palette or the plugin's own colour,
|
||||
* never both — the server picked the winner. `*Color` is guaranteed `#rrggbb` (nothing
|
||||
* else survives normalization, because it lands inside a CSS value); the client still
|
||||
* owns how strongly it renders — alpha per theme and per region, lightness clamped
|
||||
* into a readable band. */
|
||||
export type PluginDayTintTone = 'default' | 'success' | 'warn' | 'danger'
|
||||
export interface PluginDayTint {
|
||||
pluginId: string; dayId: number;
|
||||
badgeTone?: PluginDayTintTone;
|
||||
badgeColor?: string;
|
||||
headerTone?: PluginDayTintTone;
|
||||
headerColor?: string;
|
||||
activityTone?: PluginDayTintTone;
|
||||
activityColor?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** A route computed by a routeProvider plugin (server-normalized: coordinates
|
||||
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
|
||||
* or refused — the caller falls back to straight lines like on an OSRM outage. */
|
||||
export interface PluginRouteResult {
|
||||
pluginId: string; profile: string;
|
||||
coordinates: Array<[number, number]>;
|
||||
distance: number; duration: number;
|
||||
legs: Array<{ distance: number; duration: number; note?: string }>;
|
||||
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
|
||||
}
|
||||
|
||||
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
|
||||
* Server-normalized: counts + lengths are capped, cells are plain strings. */
|
||||
export interface PluginPdfSection {
|
||||
@@ -803,23 +662,6 @@ export const pluginsApi = {
|
||||
// (#587). Host-normalized + range-checked; fail-safe (skips slow/failing providers).
|
||||
mapMarkers: (tripId: number | string) =>
|
||||
apiClient.get(`/map-markers/${tripId}`).then(r => r.data as { markers: PluginMapMarker[] }),
|
||||
// Vector overlays (polylines/polygons/circles) plugins draw on the trip map via
|
||||
// the mapLayerProvider hook. Host-normalized + vertex-budgeted; fail-safe.
|
||||
mapLayers: (tripId: number | string) =>
|
||||
apiClient.get(`/map-layers/${tripId}`).then(r => r.data as { layers: PluginMapLayer[] }),
|
||||
// Route the given waypoints through ONE routeProvider plugin profile (targeted,
|
||||
// not a fan-out — the user picked this profile in the route toggle). Slow by
|
||||
// design (external solvers): the server allows the plugin 20 s.
|
||||
pluginRoute: (pluginId: string, profileId: string, body: { tripId: number | string; dayId?: number | null; waypoints: Array<{ lat: number; lng: number; name?: string; placeId?: number }> }, opts: { signal?: AbortSignal } = {}) =>
|
||||
apiClient.post(`/plugin-routes/${pluginId}/${profileId}`, body, { timeout: 25000, signal: opts.signal }).then(r => r.data as { route: PluginRouteResult | null }),
|
||||
// Time contributions plugins attach to the day plan via the dayScheduleProvider
|
||||
// hook (charging stops, security buffers). Host-normalized; fail-safe.
|
||||
daySchedule: (tripId: number | string) =>
|
||||
apiClient.get(`/day-schedule/${tripId}`).then(r => r.data as { items: PluginDayScheduleItem[] }),
|
||||
// Per-day colours plugins put behind the day cards via the dayTintProvider hook
|
||||
// (which leg of the trip a day belongs to). Host-normalized; fail-safe.
|
||||
dayTints: (tripId: number | string) =>
|
||||
apiClient.get(`/day-tints/${tripId}`).then(r => r.data as { tints: PluginDayTint[] }),
|
||||
// Text-only sections plugins append to the trip PDF export via the
|
||||
// pdfSectionProvider hook. Host-normalized (counts + lengths capped); fail-safe.
|
||||
pdfSections: (tripId: number | string) =>
|
||||
@@ -897,8 +739,8 @@ export const airtrailApi = {
|
||||
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
||||
// flights + import are added with the trip-planner import (P2)
|
||||
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
||||
import: (tripId: number, flightIds: string[], connections?: string[][]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, connections?.length ? { flightIds, connections } : { flightIds }).then(r => r.data),
|
||||
import: (tripId: number, flightIds: string[]) =>
|
||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const journeyApi = {
|
||||
@@ -917,22 +759,6 @@ export const journeyApi = {
|
||||
|
||||
// Entries
|
||||
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
|
||||
// GPX tracks of the trips this journey's entries came from (#1260).
|
||||
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
|
||||
// What the journey adds up to — distance, days, countries, the route (#1973).
|
||||
// Read by TREK Studio, which freezes the answer into the book document.
|
||||
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
|
||||
|
||||
// The Studio book (#1973). `book` is null for a journey that has none yet —
|
||||
// Studio lays one out and the first save creates it.
|
||||
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
|
||||
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
|
||||
// A 409 carries the current record in its body, so a conflict can be shown
|
||||
// rather than only announced. See useBookStore.
|
||||
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
|
||||
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
|
||||
deleteBook: (id: number): Promise<void> =>
|
||||
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
|
||||
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
|
||||
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
|
||||
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
|
||||
@@ -972,58 +798,19 @@ export const journeyApi = {
|
||||
getPublicJourney: (token: string) => apiClient.get(`/public/journey/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Photo providers (Immich, Synology Photos, …) behind /api/integrations/memories.
|
||||
// The provider sits on the user's own hardware and the server proxies through to
|
||||
// it, so the 8s default does not apply — and neither does any number picked
|
||||
// here. The server already holds the deadline, and its worst case is longer than
|
||||
// anything that would look reasonable in this file: a Synology call is 30s, and
|
||||
// an expired session makes it re-authenticate and try again. A client cap below
|
||||
// that turns a slow NAS into a NAS that reads as disconnected, which is what
|
||||
// these calls looked like before they moved off raw fetch (which had no timeout
|
||||
// either). Callers that need to give up early pass an AbortSignal.
|
||||
const MEMORIES_TIMEOUT = 0
|
||||
|
||||
export const memoriesApi = {
|
||||
status: (provider: string): Promise<{ connected: boolean }> =>
|
||||
apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||
search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) =>
|
||||
apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data),
|
||||
albums: (provider: string) =>
|
||||
apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data),
|
||||
albumPhotos: (provider: string, albumId: string, passphrase?: string, signal?: AbortSignal) =>
|
||||
apiClient.get(`/integrations/memories/${provider}/albums/${albumId}/photos`, {
|
||||
timeout: MEMORIES_TIMEOUT,
|
||||
params: passphrase ? { passphrase } : undefined,
|
||||
signal,
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
// timeout than the global 8s — a cold Wikimedia connection alone can eat that.
|
||||
placeEnrichment: (
|
||||
body: { placeId?: string; lat: number; lng: number; name: string; lang?: string; details?: Record<string, unknown> },
|
||||
signal?: AbortSignal,
|
||||
) => apiClient.post('/maps/enrichment', body, { signal, timeout: 25000 })
|
||||
.then(r => checkInDev(mapsPlaceEnrichmentResultSchema, r.data, 'maps.placeEnrichment')),
|
||||
// Author + licence for a picture already stored in the photo cache, keyed by
|
||||
// the cache key embedded in its proxy URL. Local read, no provider call.
|
||||
placePhotoCredit: (key: string) =>
|
||||
apiClient.get(`/maps/enrichment/credit/${encodeURIComponent(key)}`).then(r => r.data as { credit: string | null }),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
@@ -1068,8 +855,6 @@ export const reservationsApi = {
|
||||
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
// Assign trip members / named guests to a booking (#1517).
|
||||
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
@@ -1099,9 +884,7 @@ export const healthApi = {
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
// `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614).
|
||||
get: (lat: number, lng: number, date: string, time?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getCurrent: (lat: number, lng: number, lang?: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')),
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise<WeatherResult> => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')),
|
||||
}
|
||||
|
||||
@@ -1174,7 +957,13 @@ export const backupApi = {
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
downloadBlob(await res.blob(), filename)
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
},
|
||||
delete: (filename: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
||||
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
|
||||
//
|
||||
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
|
||||
// request-body shape that the server contract depends on. These tests drive each method
|
||||
// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`.
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { collectionsApi } from './collections'
|
||||
import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared'
|
||||
|
||||
const BASE = '/api/addons/collections'
|
||||
|
||||
const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true }
|
||||
const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' }
|
||||
const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }
|
||||
|
||||
let requestUrl = ''
|
||||
let requestBody: unknown
|
||||
|
||||
beforeEach(() => {
|
||||
requestUrl = ''
|
||||
requestBody = undefined
|
||||
})
|
||||
|
||||
/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */
|
||||
function record<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')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
|
||||
server.use(http.post(BASE, record({ collection })))
|
||||
|
||||
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
|
||||
|
||||
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
|
||||
expect(res.collection.id).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
|
||||
server.use(http.patch(`${BASE}/:id`, record({ collection })))
|
||||
|
||||
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1`)
|
||||
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
|
||||
expect(res.collection).toEqual(collection)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/:id/cover`, record(collection)))
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('cover', new File(['x'], 'cover.jpg'))
|
||||
const res = await collectionsApi.uploadCover(1, fd)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1/cover`)
|
||||
expect(res).toEqual(collection)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => {
|
||||
server.use(http.delete(`${BASE}/:id`, record({ success: true })))
|
||||
|
||||
const res = await collectionsApi.remove(4)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/4`)
|
||||
expect(res).toEqual({ success: true })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => {
|
||||
server.use(http.post(`${BASE}/reorder`, record({ success: true })))
|
||||
|
||||
await collectionsApi.reorder([3, 1, 2])
|
||||
|
||||
expect(requestBody).toEqual({ orderedIds: [3, 1, 2] })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => {
|
||||
server.use(http.post(`${BASE}/places`, record({ place })))
|
||||
|
||||
const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true })
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true })
|
||||
expect(res.place).toEqual(place)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => {
|
||||
server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } })))
|
||||
|
||||
const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 })
|
||||
expect(res.duplicate).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => {
|
||||
server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] })))
|
||||
|
||||
const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true })
|
||||
expect(res.copied).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => {
|
||||
server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' })))
|
||||
|
||||
const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/places/10`)
|
||||
expect(requestBody).toEqual({ notes: 'busy at night' })
|
||||
expect(res.notes).toBe('busy at night')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' })))
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('image', new File(['x'], 'p.jpg'))
|
||||
const res = await collectionsApi.uploadPlaceImage(10, fd)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/places/10/image`)
|
||||
expect(res.image_url).toBe('/uploads/p.jpg')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => {
|
||||
server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' })))
|
||||
|
||||
const res = await collectionsApi.setStatus(10, 'visited')
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/places/10/status`)
|
||||
expect(requestBody).toEqual({ status: 'visited' })
|
||||
expect(res.status).toBe('visited')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => {
|
||||
server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 })))
|
||||
|
||||
const res = await collectionsApi.ratePlace(10, 4)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/places/10/rating`)
|
||||
expect(requestBody).toEqual({ rating: 4 })
|
||||
expect(res.rating_avg).toBe(4)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => {
|
||||
let deleted = false
|
||||
server.use(
|
||||
http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })),
|
||||
http.delete(`${BASE}/places/:pid/rating`, () => {
|
||||
deleted = true
|
||||
return HttpResponse.json({ ...place, rating_avg: null })
|
||||
}),
|
||||
)
|
||||
|
||||
const res = await collectionsApi.ratePlace(10, null)
|
||||
|
||||
expect(deleted).toBe(true)
|
||||
expect(res.rating_avg).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => {
|
||||
server.use(http.delete(`${BASE}/places/:pid`, record({ success: true })))
|
||||
|
||||
await collectionsApi.deletePlace(10)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/places/10`)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => {
|
||||
server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 })))
|
||||
|
||||
const res = await collectionsApi.deleteMany([10, 11])
|
||||
|
||||
expect(requestBody).toEqual({ ids: [10, 11] })
|
||||
expect(res).toEqual({ deleted: 2 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => {
|
||||
server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] })))
|
||||
|
||||
const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] })
|
||||
|
||||
expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] })
|
||||
expect(res.copied).toBe(1)
|
||||
expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }])
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => {
|
||||
server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] })))
|
||||
|
||||
const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 })
|
||||
const params = new URL(requestUrl).searchParams
|
||||
|
||||
expect(params.get('google_place_id')).toBe('g1')
|
||||
expect(params.get('lat')).toBe('35.6')
|
||||
expect(params.get('lng')).toBe('139.7')
|
||||
expect(res.saved).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => {
|
||||
server.use(http.post(`${BASE}/invite`, record({ success: true })))
|
||||
|
||||
await collectionsApi.invite(1, 5, 'admin')
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => {
|
||||
server.use(http.post(`${BASE}/members/role`, record({ success: true })))
|
||||
|
||||
await collectionsApi.setMemberRole(1, 5, 'viewer')
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => {
|
||||
server.use(http.post(`${BASE}/invite/accept`, record({ success: true })))
|
||||
|
||||
await collectionsApi.acceptInvite(1)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => {
|
||||
server.use(http.post(`${BASE}/invite/decline`, record({ success: true })))
|
||||
|
||||
await collectionsApi.declineInvite(2)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 2 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => {
|
||||
server.use(http.post(`${BASE}/invite/cancel`, record({ success: true })))
|
||||
|
||||
await collectionsApi.cancelInvite(1, 5)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, user_id: 5 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => {
|
||||
server.use(http.post(`${BASE}/leave`, record({ success: true })))
|
||||
|
||||
await collectionsApi.leave(3)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 3 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => {
|
||||
server.use(http.post(`${BASE}/members/remove`, record({ success: true })))
|
||||
|
||||
await collectionsApi.removeMember(1, 9)
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, user_id: 9 })
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => {
|
||||
server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] })))
|
||||
|
||||
const res = await collectionsApi.availableUsers(1)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1/available-users`)
|
||||
expect(res.users).toEqual([{ id: 5, username: 'bob' }])
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => {
|
||||
server.use(http.post(`${BASE}/labels`, record(label)))
|
||||
|
||||
const res = await collectionsApi.createLabel(1, 'Food', '#ef4444')
|
||||
|
||||
expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' })
|
||||
expect(res).toEqual(label)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => {
|
||||
server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' })))
|
||||
|
||||
const res = await collectionsApi.updateLabel(3, { name: 'Eats' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/labels/3`)
|
||||
expect(requestBody).toEqual({ name: 'Eats' })
|
||||
expect(res.name).toBe('Eats')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => {
|
||||
server.use(http.delete(`${BASE}/labels/:id`, record({ success: true })))
|
||||
|
||||
await collectionsApi.deleteLabel(3)
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/labels/3`)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => {
|
||||
server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 })))
|
||||
|
||||
const res = await collectionsApi.assignLabels([3], [10, 11])
|
||||
|
||||
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] })
|
||||
expect(res.changed).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => {
|
||||
server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 })))
|
||||
|
||||
const res = await collectionsApi.unassignLabels([3], [10])
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/labels/unassign`)
|
||||
expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] })
|
||||
expect(res.changed).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
CollectionUpdateRequest,
|
||||
CollectionSavePlaceRequest,
|
||||
CollectionSaveFromTripRequest,
|
||||
CollectionImportablesResponse,
|
||||
CollectionPlaceUpdateRequest,
|
||||
CollectionCopyToTripRequest,
|
||||
CollectionInviteRequest,
|
||||
@@ -17,7 +16,6 @@ import type {
|
||||
CollectionInviteActionRequest,
|
||||
CollectionInviteCancelRequest,
|
||||
CollectionStatus,
|
||||
CollectionSetStatusManyResponse,
|
||||
Collection,
|
||||
CollectionPlace,
|
||||
CollectionLabel,
|
||||
@@ -70,22 +68,10 @@ export const collectionsApi = {
|
||||
ax.post(`${base}/places/from-trip`, body satisfies CollectionSaveFromTripRequest).then((r: AxiosResponse) => r.data),
|
||||
saveFromTripMany: (collectionId: number, tripId: number, placeIds: number[], force?: boolean): Promise<{ copied: number; skipped: { id: number; name: string }[] }> =>
|
||||
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).then((r: AxiosResponse) => r.data),
|
||||
importable: (collectionId: number, tripId: number): Promise<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> =>
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011
|
||||
// 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 } from './oauthScopes'
|
||||
|
||||
// The consent page mirrors the server's scope list by hand, so read the server
|
||||
// file and compare. Parsing keeps this a client-only test (no server import).
|
||||
function readServerScopes(): string[] {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const src = readFileSync(resolve(here, '../../../server/src/mcp/scopes.ts'), 'utf8')
|
||||
const block = src.slice(src.indexOf('export const SCOPES = {'))
|
||||
const body = block.slice(0, block.indexOf('} as const;'))
|
||||
return [...body.matchAll(/^\s*[A-Z_]+:\s*'([a-z]+:[a-z]+)'/gm)].map(m => m[1])
|
||||
}
|
||||
|
||||
describe('SCOPE_GROUPS', () => {
|
||||
it('FE-OAUTH-SCOPES-001: contains all expected scope keys', () => {
|
||||
const expected = [
|
||||
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||
'places:read', 'places:write',
|
||||
'collections:read', 'collections:write',
|
||||
'atlas:read', 'atlas:write',
|
||||
'packing:read', 'packing:write',
|
||||
'todos:read', 'todos:write',
|
||||
@@ -30,7 +16,6 @@ describe('SCOPE_GROUPS', () => {
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -47,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)', () => {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
|
||||
@@ -119,11 +110,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 +166,6 @@ export function disconnect(): void {
|
||||
reconnectTimer = null
|
||||
}
|
||||
activeTrips.clear()
|
||||
activeBooks.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
@@ -202,40 +187,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,104 +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:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
'collab:poll:deleted',
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
// Collections — pages/collections/useCollections ('collections:' prefix listener)
|
||||
'collections:updated',
|
||||
'collections:accepted',
|
||||
'collections:declined',
|
||||
'collections:left',
|
||||
'collections:deleted',
|
||||
'collections:cancelled',
|
||||
'collections:removed',
|
||||
'collections:invite',
|
||||
// Vacay — pages/vacay/useVacay
|
||||
'vacay:update',
|
||||
'vacay:settings',
|
||||
'vacay:accepted',
|
||||
'vacay:declined',
|
||||
'vacay:cancelled',
|
||||
'vacay:dissolved',
|
||||
'vacay:invite',
|
||||
'vacay:share',
|
||||
'vacay:share-removed',
|
||||
'vacay:shared-update',
|
||||
// Journey — pages/journeyDetail/useJourneyDetail ('journey:' prefix listener)
|
||||
'journey:trip:synced',
|
||||
'journey:entry:created',
|
||||
'journey:entry:updated',
|
||||
'journey:entry:deleted',
|
||||
'journey:entries:reordered',
|
||||
'journey:contributor:changed',
|
||||
// Studio book — components/Studio/useBookStore (its own listener: a client
|
||||
// with nothing outstanding takes the new version, one with unsaved edits
|
||||
// deliberately does not and conflicts on its next save instead)
|
||||
'journey:book:saved',
|
||||
// Studio presence — components/Studio/useBookPresence (its own listener:
|
||||
// who has the book open, and where their pointers are)
|
||||
'journey:book:peers',
|
||||
'journey:book:cursor',
|
||||
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
|
||||
'import:progress',
|
||||
'import:done',
|
||||
'import:error',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
|
||||
/**
|
||||
* Events the client deliberately does not act on today (state of the world
|
||||
* when the registry landed — every one of these was already dropped by the
|
||||
* old silent `default:` branches). Removing an entry means the event is now
|
||||
* handled somewhere; ADDING an entry is a product decision that a new server
|
||||
* event should have no client reaction — never add one just to silence the
|
||||
* registry-parity test.
|
||||
*/
|
||||
export const IGNORED_WS_EVENTS = [
|
||||
'assignment:participants',
|
||||
'packing:reordered',
|
||||
'packing:bag-created',
|
||||
'packing:bag-updated',
|
||||
'packing:bag-deleted',
|
||||
'packing:bag-members-updated',
|
||||
'packing:assignees',
|
||||
'packing:template-applied',
|
||||
'todo:assignees',
|
||||
'budget:settlement-created',
|
||||
'budget:settlement-updated',
|
||||
'budget:settlement-deleted',
|
||||
'reservation:positions',
|
||||
// Accommodations live in page-local planner state; the client refetches
|
||||
// them off trip:updated date changes, never off these events.
|
||||
'accommodation:created',
|
||||
'accommodation:updated',
|
||||
'accommodation:deleted',
|
||||
'trip:deleted',
|
||||
'member:added',
|
||||
'member:removed',
|
||||
] as const satisfies readonly TrekWsEventName[]
|
||||
@@ -1,7 +1,7 @@
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
|
||||
// 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,45 +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 })) });
|
||||
});
|
||||
}
|
||||
|
||||
/** The pill toggle of a top-level addon row. */
|
||||
function addonToggle(name: string): HTMLElement {
|
||||
const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
|
||||
/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */
|
||||
function subToggle(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
return within(row).getByRole('button');
|
||||
}
|
||||
|
||||
function isOn(toggle: HTMLElement): boolean {
|
||||
return toggle.style.background === 'var(--text-primary)';
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
@@ -98,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 () => {
|
||||
@@ -268,360 +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 () => {
|
||||
seedStore(useSettingsStore, { settings: { dark_mode: '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('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(immichRow.querySelector('svg')).toBeInTheDocument();
|
||||
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
|
||||
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
|
||||
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
|
||||
|
||||
expect(isOn(subToggle('Immich'))).toBe(true);
|
||||
expect(isOn(subToggle('Unsplash'))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: unknown = null;
|
||||
server.use(
|
||||
addonsRoute([
|
||||
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
|
||||
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
|
||||
]),
|
||||
http.put('/api/admin/addons/immich', async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><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-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-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');
|
||||
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls: (string | null)[] = [];
|
||||
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
|
||||
render(<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:8b is already installed, so the recommended row offers "Use" instead of "Pull"
|
||||
await user.click(screen.getByRole('button', { name: 'Use' }));
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local' })]),
|
||||
http.get('/api/admin/llm/local/models', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'down' }, { status: 500 })
|
||||
: HttpResponse.json({ models: [] });
|
||||
}),
|
||||
);
|
||||
render(<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:8b', size: 1 }] });
|
||||
}),
|
||||
http.post('/api/admin/llm/local/pull', async ({ request }) => {
|
||||
pulled = await request.json();
|
||||
await delay(150);
|
||||
return new HttpResponse(
|
||||
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
|
||||
{ headers: { 'Content-Type': 'application/x-ndjson' } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
render(<><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:8b' });
|
||||
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
|
||||
modelsRoute([]),
|
||||
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
|
||||
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
|
||||
}),
|
||||
);
|
||||
render(<><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('Connection');
|
||||
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
|
||||
expect(urls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
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'
|
||||
@@ -27,7 +27,7 @@ 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,
|
||||
}
|
||||
@@ -196,7 +196,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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 type="button" onClick={onToggleBagTracking}
|
||||
<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"
|
||||
@@ -222,7 +222,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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 type="button" onClick={() => onToggleCollabFeature(feat.key)}
|
||||
<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"
|
||||
@@ -269,7 +269,7 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking,
|
||||
<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 type="button"
|
||||
<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)' }}
|
||||
@@ -399,7 +399,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
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 } })
|
||||
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
|
||||
toast.success('Saved')
|
||||
} catch {
|
||||
toast.error('Failed to save')
|
||||
@@ -451,7 +451,6 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<section className="space-y-3">
|
||||
<div className={sectionCls}>Model</div>
|
||||
<label className="block">
|
||||
<span className="sr-only">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>
|
||||
|
||||
@@ -460,7 +459,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
<div className="space-y-3 rounded-lg border border-edge-secondary bg-surface p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-content-secondary">Installed on the server</span>
|
||||
<button type="button" onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
<button onClick={loadModels} disabled={loadingModels} className="text-xs text-content-muted underline disabled:opacity-60">
|
||||
{loadingModels ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -471,7 +470,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
{installed.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{installed.map(name => (
|
||||
<button type="button"
|
||||
<button
|
||||
key={name}
|
||||
title={name}
|
||||
onClick={() => setModel(name)}
|
||||
@@ -510,11 +509,11 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</div>
|
||||
{installedHere ? (
|
||||
<button type="button" onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${active ? 'bg-surface-tertiary text-content-muted' : 'border border-edge-secondary text-content-secondary hover:border-edge'}`}>
|
||||
<button onClick={() => setModel(m.id)} disabled={active} className={`shrink-0 rounded-md px-3 py-1.5 text-xs 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-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
<button onClick={() => pull(m.id)} disabled={!!pulling} className="shrink-0 rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-accent-text disabled:opacity-60">
|
||||
{isPulling ? 'Pulling…' : 'Pull'}
|
||||
</button>
|
||||
)}
|
||||
@@ -527,7 +526,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<button type="button" onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
<button onClick={save} disabled={saving} className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-accent-text transition-opacity disabled:opacity-60">
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -590,7 +589,7 @@ function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statu
|
||||
{isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
{!hideToggle && (
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -310,292 +310,4 @@ describe('BackupPanel', () => {
|
||||
expect(screen.getByRole('button', { name: /^save$/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
// BKP-015: List request fails
|
||||
it('FE-ADMIN-BKP-015: a failing list request toasts and keeps the empty state', async () => {
|
||||
server.use(http.get('/api/backup/list', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
|
||||
expect(await screen.findByText('Failed to load backups')).toBeInTheDocument()
|
||||
expect(screen.getByText('No backups yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-016: Create fails
|
||||
it('FE-ADMIN-BKP-016: a failing create toasts the error and re-enables the button', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.post('/api/backup/create', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getByTitle('Create Backup'))
|
||||
|
||||
expect(await screen.findByText('Failed to create backup')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTitle('Create Backup')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-017: Restore fails
|
||||
it('FE-ADMIN-BKP-017: a failing restore surfaces the server message and clears the spinner', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/restore/:filename', () =>
|
||||
HttpResponse.json({ error: 'archive is corrupt' }, { status: 400 }),
|
||||
),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
await user.click(await screen.findByText('Yes, restore'))
|
||||
|
||||
expect(await screen.findByText('archive is corrupt')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getAllByText('Restore')[0].closest('button')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-018: Upload & restore happy path
|
||||
it('FE-ADMIN-BKP-018: picking a file opens the modal and uploads it on confirm', async () => {
|
||||
const user = userEvent.setup()
|
||||
let uploaded = false
|
||||
server.use(
|
||||
http.post('/api/backup/upload-restore', () => {
|
||||
uploaded = true
|
||||
return HttpResponse.json({ success: true })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const reloadMock = vi.fn()
|
||||
vi.stubGlobal('location', { ...window.location, reload: reloadMock })
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['zip'], 'restore-me.zip', { type: 'application/zip' }))
|
||||
|
||||
expect(await screen.findByText('Restore Backup?')).toBeInTheDocument()
|
||||
expect(screen.getByText('restore-me.zip')).toBeInTheDocument()
|
||||
// The picked file is cleared from the input so the same file can be chosen again
|
||||
expect(input.value).toBe('')
|
||||
|
||||
await user.click(screen.getByText('Yes, restore'))
|
||||
await waitFor(() => expect(uploaded).toBe(true))
|
||||
expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
// BKP-019: Upload & restore failure
|
||||
it('FE-ADMIN-BKP-019: a failing upload restore toasts and re-enables the upload button', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(
|
||||
http.post('/api/backup/upload-restore', () => HttpResponse.json({ error: 'not a backup' }, { status: 400 })),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
await user.upload(input, new File(['zip'], 'broken.zip', { type: 'application/zip' }))
|
||||
await user.click(await screen.findByText('Yes, restore'))
|
||||
|
||||
expect(await screen.findByText('not a backup')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByTitle('Upload Backup')).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-020: Upload button forwards the click to the hidden file input
|
||||
it('FE-ADMIN-BKP-020: the Upload button opens the hidden file picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
await user.click(screen.getByTitle('Upload Backup'))
|
||||
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// BKP-021: Delete declined / failing
|
||||
it('FE-ADMIN-BKP-021: declining the confirm keeps the backup, a failing delete toasts', async () => {
|
||||
const user = userEvent.setup()
|
||||
let deleteCalls = 0
|
||||
server.use(
|
||||
http.delete('/api/backup/:filename', () => {
|
||||
deleteCalls += 1
|
||||
return HttpResponse.json({ error: 'file is locked' }, { status: 500 })
|
||||
}),
|
||||
)
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
const trashBtn = Array.from(document.querySelectorAll('button')).find(
|
||||
b => b.querySelector('svg.lucide-trash2'),
|
||||
) as HTMLElement
|
||||
await user.click(trashBtn)
|
||||
expect(deleteCalls).toBe(0)
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
|
||||
confirmSpy.mockReturnValue(true)
|
||||
await user.click(trashBtn)
|
||||
|
||||
expect(await screen.findByText('Failed to delete')).toBeInTheDocument()
|
||||
expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-022: Auto settings save fails
|
||||
it('FE-ADMIN-BKP-022: a failing auto-settings save toasts and keeps the form dirty', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.put('/api/backup/auto-settings', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Enable auto-backup')
|
||||
|
||||
await user.click(getToggleButton())
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
expect(await screen.findByText('Failed to save settings')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^save$/i })).toBeEnabled())
|
||||
})
|
||||
|
||||
// BKP-023: Size/date fallbacks
|
||||
it('FE-ADMIN-BKP-023: missing size and date render as a dash, kilobytes are formatted', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/list', () =>
|
||||
HttpResponse.json({
|
||||
backups: [
|
||||
{ filename: 'empty.zip', created_at: null, size: 0 },
|
||||
{ filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 5120 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('empty.zip')
|
||||
|
||||
expect(screen.getAllByText('-')).toHaveLength(2)
|
||||
expect(screen.getByText('5.0 KB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-024: Invalid server timezone
|
||||
it('FE-ADMIN-BKP-024: an unusable server timezone falls back to the raw timestamp', async () => {
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'Not/AZone',
|
||||
}),
|
||||
),
|
||||
)
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await waitFor(() => expect(screen.getByText('2025-01-15T10:00:00Z')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
// BKP-025: 12h hour picker
|
||||
it('FE-ADMIN-BKP-025: the hour picker uses AM/PM labels for 12h users and stores the pick', async () => {
|
||||
const user = userEvent.setup()
|
||||
seedStore(useSettingsStore, { settings: { time_format: '12h' } } as any)
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'daily', keep_days: 7, hour: 0, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Run at hour')
|
||||
|
||||
expect(screen.getByText('Server local time (12h format) (Timezone: UTC)')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: '12:00 AM' }))
|
||||
await user.click(await screen.findByRole('button', { name: '2:00 PM' }))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ hour: 14 }))
|
||||
})
|
||||
|
||||
// BKP-026: Monthly interval
|
||||
it('FE-ADMIN-BKP-026: the monthly interval offers a day-of-month picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'monthly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: '',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Day of month')
|
||||
|
||||
// No timezone from the server → the hint carries no timezone suffix
|
||||
expect(screen.getByText('Server local time (24h format)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Sun')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '1' }))
|
||||
await user.click(await screen.findByRole('button', { name: '15' }))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ day_of_month: 15 }))
|
||||
})
|
||||
|
||||
// BKP-027: Day of week + retention
|
||||
it('FE-ADMIN-BKP-027: day-of-week and retention picks are stored together', async () => {
|
||||
const user = userEvent.setup()
|
||||
let saved: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.get('/api/backup/auto-settings', () =>
|
||||
HttpResponse.json({
|
||||
settings: { enabled: true, interval: 'weekly', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 },
|
||||
timezone: 'UTC',
|
||||
}),
|
||||
),
|
||||
http.put('/api/backup/auto-settings', async ({ request }) => {
|
||||
saved = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ settings: saved })
|
||||
}),
|
||||
)
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('Day of week')
|
||||
|
||||
await user.click(screen.getByText('Fri'))
|
||||
await user.click(screen.getByText('Keep forever'))
|
||||
await user.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(saved).toMatchObject({ day_of_week: 5, keep_days: 0 }))
|
||||
})
|
||||
|
||||
// BKP-028: Download failure
|
||||
it('FE-ADMIN-BKP-028: a failing download toasts the download error', async () => {
|
||||
const user = userEvent.setup()
|
||||
server.use(http.get('/api/backup/download/:filename', () => HttpResponse.error()))
|
||||
render(<><ToastContainer /><BackupPanel /></>)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getByText('Download'))
|
||||
|
||||
expect(await screen.findByText('Download failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// BKP-029: Confirm button hover styling
|
||||
it('FE-ADMIN-BKP-029: the destructive confirm button darkens on hover', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BackupPanel />)
|
||||
await screen.findByText('backup-2025-01-15.zip')
|
||||
|
||||
await user.click(screen.getAllByText('Restore')[0])
|
||||
const confirmBtn = await screen.findByText('Yes, restore')
|
||||
|
||||
fireEvent.mouseEnter(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(185, 28, 28)')
|
||||
fireEvent.mouseLeave(confirmBtn)
|
||||
expect(confirmBtn.style.background).toBe('rgb(220, 38, 38)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -191,7 +191,7 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={loadBackups}
|
||||
disabled={isLoading}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
@@ -208,7 +208,7 @@ export default function BackupPanel() {
|
||||
className="hidden"
|
||||
onChange={handleUploadRestore}
|
||||
/>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||
@@ -222,7 +222,7 @@ export default function BackupPanel() {
|
||||
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isCreating}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||
@@ -247,7 +247,7 @@ export default function BackupPanel() {
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
|
||||
<p className="text-sm">{t('backup.empty')}</p>
|
||||
<button type="button" onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
|
||||
{t('backup.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -274,14 +274,14 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('backup.download')}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => handleRestore(backup.filename)}
|
||||
disabled={restoringFile === backup.filename}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
|
||||
@@ -292,7 +292,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
{t('backup.restore')}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => handleDelete(backup.filename)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
@@ -318,11 +318,11 @@ export default function BackupPanel() {
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Enable toggle */}
|
||||
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||
<div className="min-w-0 text-sm font-medium text-gray-900">
|
||||
{t('backup.auto.enable')}
|
||||
<p className="text-xs font-normal text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||
</div>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: autoSettings.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
@@ -339,7 +339,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{INTERVAL_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -360,7 +360,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.hour)}
|
||||
onChange={v => handleAutoSettingsChange('hour', Number.parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('hour', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={HOURS.map(h => {
|
||||
let label: string
|
||||
@@ -386,7 +386,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map(opt => (
|
||||
<button type="button"
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -408,7 +408,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||
<CustomSelect
|
||||
value={String(autoSettings.day_of_month)}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', Number.parseInt(String(v), 10))}
|
||||
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(String(v), 10))}
|
||||
size="sm"
|
||||
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||
/>
|
||||
@@ -421,7 +421,7 @@ export default function BackupPanel() {
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{KEEP_OPTIONS.map(opt => (
|
||||
<button type="button"
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||
@@ -440,7 +440,7 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-end pt-2 border-t border-gray-100">
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={handleSaveAutoSettings}
|
||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||
@@ -458,15 +458,11 @@ export default function BackupPanel() {
|
||||
{/* Restore Warning Modal */}
|
||||
{restoreConfirm && (
|
||||
<div
|
||||
// Dismiss-on-backdrop is a mouse shortcut for the Cancel button below;
|
||||
// the backdrop itself carries no semantics of its own.
|
||||
role="presentation"
|
||||
className="bg-[rgba(0,0,0,0.5)]"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
@@ -501,14 +497,14 @@ export default function BackupPanel() {
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => setRestoreConfirm(null)}
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
className="bg-[#dc2626] text-white"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-020
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -27,17 +27,17 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-002: shows Categories title', async () => {
|
||||
render(<CategoryManager />);
|
||||
expect(await screen.findByText('Categories')).toBeInTheDocument();
|
||||
await screen.findByText('Categories');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-003: shows empty state when no categories', async () => {
|
||||
render(<CategoryManager />);
|
||||
expect(await screen.findByText('No categories yet')).toBeInTheDocument();
|
||||
await screen.findByText('No categories yet');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-004: shows New Category button', async () => {
|
||||
render(<CategoryManager />);
|
||||
expect(await screen.findByText('New Category')).toBeInTheDocument();
|
||||
await screen.findByText('New Category');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-005: clicking New Category shows form', async () => {
|
||||
@@ -129,7 +129,7 @@ describe('CategoryManager', () => {
|
||||
|
||||
it('FE-COMP-CAT-010: shows subtitle text', async () => {
|
||||
render(<CategoryManager />);
|
||||
expect(await screen.findByText('Manage categories for places')).toBeInTheDocument();
|
||||
await screen.findByText('Manage categories for places');
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-011: category count is shown', async () => {
|
||||
@@ -156,148 +156,4 @@ describe('CategoryManager', () => {
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-013: a failing list request toasts and falls back to the empty state', async () => {
|
||||
server.use(http.get('/api/categories', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
|
||||
expect(await screen.findByText('Failed to load categories')).toBeInTheDocument();
|
||||
expect(screen.getByText('No categories yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-014: editing a category sends a PUT and replaces the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.get('/api/categories', () =>
|
||||
HttpResponse.json({ categories: [buildCategory({ id: 5, name: 'Hotels', color: '#6366f1', icon: 'MapPin' })] })
|
||||
),
|
||||
http.put('/api/categories/5', async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>;
|
||||
return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Lodging', color: '#ef4444', icon: 'BedDouble' }) });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'))[0]);
|
||||
const nameInput = screen.getByDisplayValue('Hotels');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Lodging');
|
||||
await user.click(screen.getByTitle('Hotel'));
|
||||
await user.click(screen.getByText('Update'));
|
||||
|
||||
expect(await screen.findByText('Category updated')).toBeInTheDocument();
|
||||
expect(body).toEqual({ name: 'Lodging', color: '#6366f1', icon: 'BedDouble' });
|
||||
expect(screen.getByText('Lodging')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-015: a failing save surfaces the server message', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/categories', () => HttpResponse.json({ error: 'name already taken' }, { status: 409 })),
|
||||
);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('New Category');
|
||||
|
||||
await user.click(screen.getByText('New Category'));
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Parks');
|
||||
await user.click(screen.getByText('Create'));
|
||||
|
||||
expect(await screen.findByText('name already taken')).toBeInTheDocument();
|
||||
// The form stays open so the name can be corrected
|
||||
expect(screen.getByDisplayValue('Parks')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-016: declining the delete confirm keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
let deleteCalled = false;
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => { deleteCalled = true; return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
|
||||
expect(deleteCalled).toBe(false);
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-017: a failing delete toasts and keeps the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })),
|
||||
http.delete('/api/categories/9', () => HttpResponse.json({ error: 'category in use' }, { status: 409 })),
|
||||
);
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<><ToastContainer /><CategoryManager /></>);
|
||||
await screen.findByText('Parks');
|
||||
|
||||
const actionBtns = screen.getAllByRole('button').filter(b => !b.textContent?.includes('New Category'));
|
||||
await user.click(actionBtns[1]);
|
||||
|
||||
expect(await screen.findByText('category in use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Parks')).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-018: picking an icon and a preset colour updates the live preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
|
||||
// Empty name → the preview falls back to the generic label
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
await user.type(screen.getByPlaceholderText('Category name'), 'Beach day');
|
||||
await user.click(screen.getByTitle('Beach'));
|
||||
|
||||
const preview = screen.getByText('Beach day');
|
||||
expect(preview).toHaveStyle({ color: '#6366f1' });
|
||||
|
||||
await user.click(document.querySelectorAll('button[style*="background-color: rgb(239, 68, 68)"]')[0]);
|
||||
expect(screen.getByText('Beach day')).toHaveStyle({ color: '#ef4444' });
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-019: the custom colour swatch opens the native picker and adopts its value', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('New Category');
|
||||
await user.click(screen.getByText('New Category'));
|
||||
|
||||
const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement;
|
||||
const clickSpy = vi.spyOn(colorInput, 'click').mockImplementation(() => {});
|
||||
await user.click(screen.getByTitle('Choose custom color'));
|
||||
expect(clickSpy).toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(colorInput, { target: { value: '#123456' } });
|
||||
await waitFor(() => expect(screen.getByText('Category')).toHaveStyle({ color: '#123456' }));
|
||||
// A non-preset colour fills the custom swatch instead of showing the pipette
|
||||
expect(screen.getByTitle('Choose custom color')).toHaveStyle({ backgroundColor: '#123456' });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('FE-COMP-CAT-020: starting an edit closes the create form', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 3, name: 'Hotels' })] })),
|
||||
);
|
||||
render(<CategoryManager />);
|
||||
await screen.findByText('Hotels');
|
||||
|
||||
await user.click(screen.getByText('New Category'));
|
||||
expect(screen.getByPlaceholderText('Category name')).toHaveValue('');
|
||||
|
||||
const row = screen.getByText('Hotels').closest('.p-3') as HTMLElement;
|
||||
await user.click(within(row).getAllByRole('button')[0]);
|
||||
|
||||
// Only the inline edit form remains, pre-filled with the row's name
|
||||
expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1);
|
||||
expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,8 +56,8 @@ export default function CategoryManager() {
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
// The Save button carries disabled={… || !form.name.trim()}, so the name is set here.
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
|
||||
setIsSaving(true)
|
||||
try {
|
||||
if (editingId) {
|
||||
@@ -194,7 +194,7 @@ export default function CategoryManager() {
|
||||
<h2 className="font-semibold text-content">{t('categories.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted">{t('categories.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={handleStartCreate}
|
||||
<button onClick={handleStartCreate}
|
||||
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||
@@ -235,11 +235,11 @@ export default function CategoryManager() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button type="button" onClick={() => handleStartEdit(cat)}
|
||||
<button onClick={() => handleStartEdit(cat)}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => handleDelete(cat.id)}
|
||||
<button onClick={() => handleDelete(cat.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-027
|
||||
import { render, screen, waitFor, within, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildAdmin } from '../../../tests/helpers/factories';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { ToastContainer } from '../shared/Toast';
|
||||
import DefaultUserSettingsTab from './DefaultUserSettingsTab';
|
||||
|
||||
// The tile preview would pull Leaflet into jsdom; the panel only needs it to render.
|
||||
vi.mock('../Map/MapView', () => ({
|
||||
MapView: ({ tileUrl }: { tileUrl?: string }) => <div data-testid="map-preview" data-tile={tileUrl} />,
|
||||
}));
|
||||
|
||||
const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard';
|
||||
const MAPBOX_DARK = 'mapbox://styles/mapbox/dark-v11';
|
||||
const MAPBOX_NAV_NIGHT = 'mapbox://styles/mapbox/navigation-night-v1';
|
||||
const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty';
|
||||
const TILE_PLACEHOLDER = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */
|
||||
function stubDefaults(initial: Record<string, unknown> = {}) {
|
||||
const state: Record<string, unknown> = { ...initial };
|
||||
const puts: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)),
|
||||
http.put('/api/admin/default-user-settings', async ({ request }) => {
|
||||
const body = await request.json() as Record<string, unknown>;
|
||||
puts.push(body);
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (value === null) delete state[key];
|
||||
else state[key] = value;
|
||||
}
|
||||
return HttpResponse.json({ ...state });
|
||||
}),
|
||||
);
|
||||
return { puts, state };
|
||||
}
|
||||
|
||||
function withToast() {
|
||||
return render(<><ToastContainer /><DefaultUserSettingsTab /></>);
|
||||
}
|
||||
|
||||
/** The selected option button is the one drawn with the strong border token. */
|
||||
function isActive(button: HTMLElement): boolean {
|
||||
return (button.style.border || '').includes('var(--text-primary)');
|
||||
}
|
||||
|
||||
/**
|
||||
* The reset link sits inside the field's own <label>; because a button is a labelable
|
||||
* element the wrapping label becomes its accessible name, so it is queried positionally.
|
||||
*/
|
||||
function resetLink(label: string): HTMLElement {
|
||||
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
|
||||
if (!el) throw new Error(`no label found for ${label}`);
|
||||
return within(el).getByRole('button');
|
||||
}
|
||||
|
||||
function hasResetLink(label: string): boolean {
|
||||
const el = screen.getAllByText(label).find(node => node.tagName === 'LABEL');
|
||||
return !!el && within(el).queryByRole('button') !== null;
|
||||
}
|
||||
|
||||
/** The CARTO key input sits under its label inside the field's own wrapper. */
|
||||
function cartoInput(): HTMLInputElement {
|
||||
return within(screen.getByText('Shared CARTO key').closest('div') as HTMLElement).getByRole('textbox');
|
||||
}
|
||||
|
||||
/** Opens a CustomSelect by its trigger label and picks an option from the portal. */
|
||||
async function pickFromSelect(user: ReturnType<typeof userEvent.setup>, trigger: string, option: string) {
|
||||
await user.click(screen.getByRole('button', { name: trigger }));
|
||||
const choices = await screen.findAllByRole('button', { name: option });
|
||||
await user.click(choices[choices.length - 1]);
|
||||
}
|
||||
|
||||
describe('DefaultUserSettingsTab', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() });
|
||||
stubDefaults();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-001: shows the loading placeholder until the defaults arrive', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-002: renders every field with no reset links while nothing is set', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)', 'On', 'Off']) {
|
||||
expect(isActive(screen.getByRole('button', { name }))).toBe(false);
|
||||
}
|
||||
for (const label of ['Color Mode', 'Temperature Unit', 'Distance Unit', 'Time Format', 'Display currency', 'Map Template']) {
|
||||
expect(hasResetLink(label)).toBe(false);
|
||||
}
|
||||
expect(screen.getByTestId('map-preview')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-003: a failing load still renders the panel with built-in defaults', async () => {
|
||||
server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 })));
|
||||
render(<DefaultUserSettingsTab />);
|
||||
|
||||
expect(await screen.findByText('Default User Settings')).toBeInTheDocument();
|
||||
expect(hasResetLink('Map engine')).toBe(false);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-004: picking a colour mode saves it and confirms with a toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText('Default saved')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ dark_mode: 'dark' }]);
|
||||
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true));
|
||||
expect(resetLink('Color Mode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-005: a legacy boolean dark_mode still highlights the matching option', async () => {
|
||||
stubDefaults({ dark_mode: true });
|
||||
const { unmount } = render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(isActive(screen.getByRole('button', { name: 'Dark' }))).toBe(true);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(false);
|
||||
unmount();
|
||||
|
||||
stubDefaults({ dark_mode: false });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
expect(isActive(screen.getByRole('button', { name: 'Light' }))).toBe(true);
|
||||
expect(isActive(screen.getByRole('button', { name: 'Auto' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-006: unit and time-format options each save their own key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '°F Fahrenheit' }));
|
||||
await waitFor(() => expect(resetLink('Temperature Unit')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: 'mi Imperial' }));
|
||||
await waitFor(() => expect(resetLink('Distance Unit')).toBeInTheDocument());
|
||||
await user.click(screen.getByRole('button', { name: '12h (2:30 PM)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([
|
||||
{ temperature_unit: 'fahrenheit' },
|
||||
{ distance_unit: 'imperial' },
|
||||
{ time_format: '12h' },
|
||||
]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-007: a set default gets a reset link that clears it server-side', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts, state } = stubDefaults({ temperature_unit: 'celsius' });
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(isActive(screen.getByRole('button', { name: '°C Celsius' }))).toBe(true);
|
||||
await user.click(resetLink('Temperature Unit'));
|
||||
|
||||
expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument();
|
||||
expect(puts).toEqual([{ temperature_unit: null }]);
|
||||
expect(state.temperature_unit).toBeUndefined();
|
||||
await waitFor(() => expect(hasResetLink('Temperature Unit')).toBe(false));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-008: the currency picker saves the chosen code and can be reset', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ default_currency: 'USD' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, 'USD $', 'EUR €');
|
||||
await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }]));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument());
|
||||
|
||||
await user.click(resetLink('Display currency'));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ default_currency: null });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-009: the blur-booking-codes options save booleans', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'On' }));
|
||||
await waitFor(() => expect(isActive(screen.getByRole('button', { name: 'On' }))).toBe(true));
|
||||
await user.click(screen.getByRole('button', { name: 'Off' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([
|
||||
{ blur_booking_codes: true },
|
||||
{ blur_booking_codes: false },
|
||||
]));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-010: the tile preset dropdown fills the URL field and hands it to the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await pickFromSelect(user, 'Select template...', 'CartoDB Dark');
|
||||
|
||||
const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }]));
|
||||
expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(url);
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', url);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-011: a hand-typed tile URL is saved on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
const input = screen.getByPlaceholderText(TILE_PLACEHOLDER);
|
||||
// userEvent reads {...} as key descriptors, so the placeholders are omitted here
|
||||
await user.type(input, 'https://tiles.example.org/tile.png');
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/tile.png' }]));
|
||||
expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/tile.png');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-012: resetting the tile URL clears the input too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument();
|
||||
await user.click(resetLink('Map Template'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }]));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(TILE_PLACEHOLDER)).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-013: leaflet hides the GL-only token and style fields', async () => {
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(isActive(screen.getByRole('button', { name: 'Standard (free)' }))).toBe(true);
|
||||
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-014: switching to Mapbox stores the provider with its own style slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }]));
|
||||
expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-015: switching to MapLibre stores the OpenFreeMap default and hides the token field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults();
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'MapLibre (OpenFreeMap)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }]));
|
||||
expect(await screen.findByText('Map style')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue(OFM_LIBERTY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-016: switching back to the standard engine only stores the provider', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Standard (free)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'leaflet' }]));
|
||||
await waitFor(() => expect(screen.queryByText('Map style')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => {
|
||||
stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-018: a stored Mapbox style survives while the standard engine is active', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'leaflet', mapbox_style: MAPBOX_DARK });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Map style')).not.toBeInTheDocument();
|
||||
// Switching to Mapbox re-uses the stored slot instead of resetting it
|
||||
await user.click(screen.getByRole('button', { name: 'Mapbox (3D)' }));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK }]));
|
||||
expect(screen.getByDisplayValue(MAPBOX_DARK)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-019: the shared Mapbox token is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared Mapbox token');
|
||||
|
||||
const input = screen.getByPlaceholderText('pk.eyJ…');
|
||||
expect(input).toHaveValue('pk.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'pk.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared Mapbox token'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ mapbox_access_token: null }));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-020: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
const input = screen.getByDisplayValue(OFM_LIBERTY);
|
||||
await user.clear(input);
|
||||
await user.type(input, 'https://example.com/custom.json');
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }]));
|
||||
expect(input).toHaveValue(OFM_LIBERTY);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-021: the style dropdown writes the picked preset into the active provider slot', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await pickFromSelect(user, 'Mapbox Standard', 'Navigation Night');
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: MAPBOX_NAV_NIGHT }]));
|
||||
expect(screen.getByDisplayValue(MAPBOX_NAV_NIGHT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-022: resetting the style restores the provider default in the field', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_DARK });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Map style');
|
||||
|
||||
await user.click(resetLink('Map style'));
|
||||
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }]));
|
||||
await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-023: the Mapbox 3D and quality options start on their built-in defaults and save their own keys', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('3D buildings & terrain');
|
||||
|
||||
// 3D defaults to on, quality mode to off when neither is stored
|
||||
const threeD = within(screen.getByText('3D buildings & terrain').closest('div') as HTMLElement);
|
||||
expect(isActive(threeD.getByRole('button', { name: 'On' }))).toBe(true);
|
||||
const quality = within(screen.getByText('High-quality mode').closest('div') as HTMLElement);
|
||||
expect(isActive(quality.getByRole('button', { name: 'Off' }))).toBe(true);
|
||||
|
||||
await user.click(threeD.getByRole('button', { name: 'Off' }));
|
||||
await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }]));
|
||||
await user.click(quality.getByRole('button', { name: 'On' }));
|
||||
await waitFor(() => expect(puts).toHaveLength(2));
|
||||
expect(puts[1]).toEqual({ mapbox_quality_mode: true });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-024: a rejected save surfaces the request error instead of a success toast', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({})),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dark' }));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Default saved')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-025: a rejected reset surfaces the request error', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/default-user-settings', () => HttpResponse.json({ time_format: '12h' })),
|
||||
http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })),
|
||||
);
|
||||
withToast();
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
await user.click(resetLink('Time Format'));
|
||||
|
||||
expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-026: the shared CARTO key is stored on blur and cleared by its reset link', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { puts } = stubDefaults({ carto_api_key: 'ck.old' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Shared CARTO key');
|
||||
|
||||
const input = cartoInput();
|
||||
expect(input).toHaveValue('ck.old');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ck.new');
|
||||
fireEvent.blur(input);
|
||||
await waitFor(() => expect(puts).toEqual([{ carto_api_key: 'ck.new' }]));
|
||||
|
||||
// Clicking the reset link also blurs the field again, so only the last PUT is checked
|
||||
await user.click(resetLink('Shared CARTO key'));
|
||||
await waitFor(() => expect(puts[puts.length - 1]).toEqual({ carto_api_key: null }));
|
||||
await waitFor(() => expect(cartoInput()).toHaveValue(''));
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DUS-027: a managed instance hides the shared CARTO key field', async () => {
|
||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), managed: true });
|
||||
stubDefaults({ carto_api_key: 'ck.hoster' });
|
||||
render(<DefaultUserSettingsTab />);
|
||||
await screen.findByText('Default User Settings');
|
||||
|
||||
expect(screen.queryByText('Shared CARTO key')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { MapView } from '../Map/MapView'
|
||||
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
|
||||
import type { DistanceUnit, Place } from '../../types'
|
||||
import { normalizeTileUrl } from '../../utils/tileUrl'
|
||||
import {
|
||||
MAPBOX_DEFAULT_STYLE,
|
||||
defaultStyleForProvider,
|
||||
@@ -18,10 +17,9 @@ import {
|
||||
styleSettingKey,
|
||||
type GlMapProvider,
|
||||
} from '../Map/glProviders'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
|
||||
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
|
||||
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
|
||||
@@ -36,7 +34,6 @@ type Defaults = {
|
||||
default_currency?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -51,8 +48,8 @@ function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
|
||||
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
@@ -87,7 +84,7 @@ function OptionButton({
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -110,18 +107,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -143,7 +137,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -158,7 +151,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
const ResetButton = ({ field }: { field: keyof Defaults }) =>
|
||||
isSet(field) ? (
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => reset(field)}
|
||||
className="text-xs ml-2 text-content-faint underline"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
@@ -189,7 +182,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
transport_mode: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
created_at: String(new Date()),
|
||||
created_at: Date(),
|
||||
}], [])
|
||||
|
||||
if (!loaded) {
|
||||
@@ -335,30 +328,10 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
value={mapTileUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapTileUrl(e.target.value)}
|
||||
onBlur={() => save({ map_tile_url: mapTileUrl })}
|
||||
placeholder="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapDefaultHint')}</p>
|
||||
{/* The key comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.cartoKey')}
|
||||
<ResetButton field="carto_api_key" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cartoKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCartoKey(e.target.value)}
|
||||
onBlur={() => save({ carto_api_key: cartoKey })}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
@@ -405,9 +378,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
|
||||
{mapProvider !== 'leaflet' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
{/* The token comes with the instance on a managed install, injected when the
|
||||
settings are read. A field here would only let somebody save a worse one. */}
|
||||
{mapProvider === 'mapbox-gl' && !managed && (
|
||||
{mapProvider === 'mapbox-gl' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -87,7 +87,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument();
|
||||
await screen.findByText('Sent: simple-me');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-007: all buttons disabled while a send is in-flight', async () => {
|
||||
@@ -101,8 +101,8 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
// The request handler never resolves, so sending stays true after the click settles
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
// Fire the click but do not await — handler never resolves so sending stays true
|
||||
void user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole('button');
|
||||
@@ -110,20 +110,7 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: the server error field is what the toast shows', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ error: 'No channel configured' }, { status: 500 }),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('No channel configured')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
@@ -133,7 +120,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
expect(await screen.findByText('Failed')).toBeInTheDocument();
|
||||
await screen.findByText(/failed|error/i);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
@@ -170,141 +157,4 @@ describe('DevNotificationsPanel', () => {
|
||||
await screen.findByText('User-Scoped Events');
|
||||
expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-011: the remaining self/admin type buttons each fire their own event', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
await user.click(screen.getByText('Boolean → Me').closest('button')!);
|
||||
await screen.findByText('Sent: boolean-me');
|
||||
await user.click(screen.getByText('Navigate → Me').closest('button')!);
|
||||
await screen.findByText('Sent: navigate-me');
|
||||
await user.click(screen.getByText('Simple → All Admins').closest('button')!);
|
||||
await screen.findByText('Sent: simple-admins');
|
||||
await user.click(screen.getByText('version_available').closest('button')!);
|
||||
await screen.findByText('Sent: version_available');
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'test_boolean',
|
||||
scope: 'user',
|
||||
targetId: ADMIN_USER.id,
|
||||
inApp: {
|
||||
type: 'boolean',
|
||||
positiveCallback: { action: 'test_approve', payload: {} },
|
||||
negativeCallback: { action: 'test_deny', payload: {} },
|
||||
},
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: ADMIN_USER.id });
|
||||
expect(bodies[2]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 });
|
||||
expect(bodies[3]).toMatchObject({ event: 'version_available', scope: 'admin', targetId: 0, params: { version: '9.9.9-test' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-012: every trip-scoped button carries the selected trip and the actor', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
|
||||
const [tripSelect] = screen.getAllByRole('combobox');
|
||||
const tripId = Number((tripSelect as HTMLSelectElement).value);
|
||||
|
||||
for (const label of ['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']) {
|
||||
await user.click(screen.getByText(label).closest('button')!);
|
||||
await screen.findByText(`Sent: ${label}`);
|
||||
}
|
||||
|
||||
expect(bodies.map(b => b.event)).toEqual(['trip_reminder', 'photos_shared', 'collab_message', 'packing_tagged']);
|
||||
for (const body of bodies) {
|
||||
expect(body.scope).toBe('trip');
|
||||
expect(body.targetId).toBe(tripId);
|
||||
expect(body.params).toMatchObject({ trip: 'Paris Adventure', tripId: String(tripId) });
|
||||
}
|
||||
expect(bodies[1].params).toMatchObject({ actor: 'testadmin', count: '5' });
|
||||
expect(bodies[2].params).toMatchObject({ preview: 'This is a test message preview.' });
|
||||
expect(bodies[3].params).toMatchObject({ category: 'Clothing' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-013: user-scoped events target the picked recipient', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', async ({ request }) => {
|
||||
bodies.push(await request.json() as Record<string, unknown>);
|
||||
return HttpResponse.json({ ok: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('User-Scoped Events');
|
||||
|
||||
const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement;
|
||||
const aliceOption = Array.from(userSelect.querySelectorAll('option')).find(
|
||||
o => (o.textContent ?? '').includes('alice'),
|
||||
)!;
|
||||
await user.selectOptions(userSelect, aliceOption.value);
|
||||
const aliceId = Number(aliceOption.value);
|
||||
|
||||
await user.click(screen.getByText('trip_invite').closest('button')!);
|
||||
await screen.findByText(`Sent: trip_invite-${aliceId}`);
|
||||
await user.click(screen.getByText('vacay_invite').closest('button')!);
|
||||
await screen.findByText(`Sent: vacay_invite-${aliceId}`);
|
||||
|
||||
expect(bodies[0]).toMatchObject({
|
||||
event: 'trip_invite',
|
||||
scope: 'user',
|
||||
targetId: aliceId,
|
||||
params: { actor: 'testadmin', invitee: 'alice@example.com' },
|
||||
});
|
||||
expect(bodies[1]).toMatchObject({
|
||||
event: 'vacay_invite',
|
||||
scope: 'user',
|
||||
targetId: aliceId,
|
||||
params: { actor: 'testadmin', planId: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-014: the User-Scoped section is hidden when no users come back', async () => {
|
||||
server.use(http.get('/api/admin/users', () => HttpResponse.json({ users: [] })));
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
|
||||
await screen.findByText('Trip-Scoped Events');
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-015: failing lookups leave both scoped sections out without crashing', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips', () => HttpResponse.error()),
|
||||
http.get('/api/admin/users', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
|
||||
expect(await screen.findByText('Type Testing')).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument());
|
||||
expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-016: hovering a trigger paints and restores its background', async () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
|
||||
const btn = screen.getByText('Simple → Me').closest('button')!;
|
||||
fireEvent.mouseEnter(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-hover)');
|
||||
fireEvent.mouseLeave(btn);
|
||||
expect(btn.style.background).toBe('var(--bg-card)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { adminApi, tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import {
|
||||
@@ -47,8 +46,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed'))
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
@@ -66,7 +65,7 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
}: {
|
||||
id: string; label: string; sub: string; icon: React.ElementType; color: string; onClick: () => void
|
||||
}) => (
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={sending !== null}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left w-full border-edge bg-surface-card"
|
||||
|
||||
@@ -231,7 +231,7 @@ describe('GitHubPanel', () => {
|
||||
render(<GitHubPanel />);
|
||||
await screen.findByText('v1.7.0');
|
||||
await user.click(screen.getByText('Show details'));
|
||||
expect(await screen.findByText('This is a plain paragraph without any markdown syntax.')).toBeInTheDocument();
|
||||
await screen.findByText('This is a plain paragraph without any markdown syntax.');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-GH-014: markdown link with safe href renders as anchor', async () => {
|
||||
|
||||
@@ -1,187 +1,146 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
const REPO = 'liketrek/TREK';
|
||||
const PER_PAGE = 10;
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
|
||||
interface GithubRelease {
|
||||
id: number;
|
||||
prerelease: boolean;
|
||||
tag_name: string;
|
||||
name: string | null;
|
||||
body: string | null;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
author: { login: string } | null;
|
||||
[key: string]: unknown;
|
||||
id: number
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
author: { login: string } | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setReleases((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(data.length === PER_PAGE);
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchReleases(1).finally(() => setLoading(false));
|
||||
}, []);
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1;
|
||||
setLoadingMore(true);
|
||||
await fetchReleases(next, true);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
style={{ background: 'var(--text-faint)' }}
|
||||
/>
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
listItems = [];
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const inlineFormat = (text) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
)
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
});
|
||||
};
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
);
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
);
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2));
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList();
|
||||
flushList()
|
||||
elements.push(
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs text-content-muted"
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
};
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -194,28 +153,11 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -228,31 +170,12 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
@@ -262,33 +185,16 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -298,31 +204,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -332,31 +221,14 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -369,134 +241,137 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="mt-1 text-xs text-content-faint">{error}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
|
||||
{isLatest && (
|
||||
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button type="button"
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button type="button"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -18,23 +18,6 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Template rows carry [chevron, name, edit, delete]; category headers [add item, edit, delete]. */
|
||||
function rowButtons(name: string, selector: string): HTMLElement[] {
|
||||
const row = screen.getByText(name).closest(selector) as HTMLElement;
|
||||
return within(row).getAllByRole('button');
|
||||
}
|
||||
|
||||
const templateButtons = (name: string) => rowButtons(name, '.px-5.py-3');
|
||||
const categoryButtons = (name: string) => rowButtons(name, '.bg-slate-50');
|
||||
const itemButtons = (name: string) => rowButtons(name, '.group');
|
||||
|
||||
/** Expands the single fixture template and waits for its content. */
|
||||
async function expandBeachTrip(user: ReturnType<typeof userEvent.setup>, firstChild: string) {
|
||||
await screen.findByText('Beach Trip');
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
await screen.findByText(firstChild);
|
||||
}
|
||||
|
||||
describe('PackingTemplateManager', () => {
|
||||
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
|
||||
server.use(
|
||||
@@ -250,7 +233,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
expect(await screen.findByText('Electronics')).toBeInTheDocument();
|
||||
await screen.findByText('Electronics');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
|
||||
@@ -280,7 +263,7 @@ describe('PackingTemplateManager', () => {
|
||||
await user.type(itemInput, 'Sandals');
|
||||
// Submit via Enter key (the input's onKeyDown handler triggers handleAddItem)
|
||||
await user.type(itemInput, '{Enter}');
|
||||
expect(await screen.findByText('Sandals')).toBeInTheDocument();
|
||||
await screen.findByText('Sandals');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
|
||||
@@ -312,7 +295,7 @@ describe('PackingTemplateManager', () => {
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
expect(await screen.findByText('Shoes')).toBeInTheDocument();
|
||||
await screen.findByText('Shoes');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
|
||||
@@ -379,7 +362,7 @@ describe('PackingTemplateManager', () => {
|
||||
const input = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Tank Top{Enter}');
|
||||
expect(await screen.findByText('Tank Top')).toBeInTheDocument();
|
||||
await screen.findByText('Tank Top');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
|
||||
@@ -525,296 +508,4 @@ describe('PackingTemplateManager', () => {
|
||||
expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-021: a failing template list toasts and shows the empty state', async () => {
|
||||
server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error()));
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
|
||||
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-022: a failing expand toasts and leaves the template without content', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(screen.getByText('Beach Trip'));
|
||||
|
||||
expect(await screen.findByText('Failed to load templates')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-023: an empty name is not submitted and a failing create toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.post('/api/admin/packing-templates', () => {
|
||||
posts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('No templates created yet');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /new template/i }));
|
||||
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
|
||||
await user.type(input, ' {Enter}');
|
||||
expect(posts).toBe(0);
|
||||
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Ski trip{Enter}');
|
||||
expect(await screen.findByText('Failed to create template')).toBeInTheDocument();
|
||||
expect(posts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-024: deleting the expanded template collapses it, a failing delete toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1', () => {
|
||||
calls += 1;
|
||||
return calls === 1
|
||||
? HttpResponse.json({ error: 'in use' }, { status: 500 })
|
||||
: HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[3]);
|
||||
await screen.findByText('Template deleted');
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('No templates created yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-025: a blank rename closes the editor, a failing rename toasts, blur commits', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.put('/api/admin/packing-templates/1', () => {
|
||||
puts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
await user.clear(screen.getByDisplayValue('Beach Trip'));
|
||||
await user.type(screen.getByRole('textbox'), '{Enter}');
|
||||
await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// Blurring the field commits the pending name — here the request fails
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
const input = screen.getByDisplayValue('Beach Trip');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Winter');
|
||||
await user.tab();
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-026: a blank category is not posted, a failing add toasts and X cancels', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
|
||||
http.post('/api/admin/packing-templates/1/categories', () => {
|
||||
posts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Add category');
|
||||
|
||||
await user.click(screen.getByText('Add category'));
|
||||
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
|
||||
await user.type(catInput, ' {Enter}');
|
||||
expect(posts).toBe(0);
|
||||
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Electronics{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
|
||||
const cancel = within(catInput.parentElement as HTMLElement).getAllByRole('button')[1];
|
||||
await user.click(cancel);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-027: a blank category rename closes the editor and a failing rename toasts', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.put('/api/admin/packing-templates/1/categories/10', () => {
|
||||
puts += 1;
|
||||
return HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
await user.clear(screen.getByDisplayValue('Clothing'));
|
||||
await user.tab();
|
||||
await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
await user.click(categoryButtons('Clothing')[1]);
|
||||
const catInput = screen.getByDisplayValue('Clothing');
|
||||
await user.clear(catInput);
|
||||
await user.type(catInput, 'Shoes{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(puts).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-028: a failing category delete toasts and keeps the category', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[2]);
|
||||
|
||||
expect(await screen.findByText('Failed to delete category')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-029: the add-item button posts the item, a failing add toasts and X closes the row', async () => {
|
||||
const user = userEvent.setup();
|
||||
let posts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
|
||||
http.post('/api/admin/packing-templates/1/categories/10/items', () => {
|
||||
posts += 1;
|
||||
return posts === 1
|
||||
? HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 0 } })
|
||||
: HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'Clothing');
|
||||
|
||||
await user.click(categoryButtons('Clothing')[0]);
|
||||
const itemInput = screen.getByPlaceholderText('Item name');
|
||||
const addRow = itemInput.parentElement as HTMLElement;
|
||||
expect(within(addRow).getAllByRole('button')[0]).toBeDisabled();
|
||||
|
||||
await user.type(itemInput, 'Sandals');
|
||||
await user.click(within(addRow).getAllByRole('button')[0]);
|
||||
await screen.findByText('Sandals');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Item name'), 'Towel');
|
||||
await user.click(within(addRow).getAllByRole('button')[0]);
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
|
||||
await user.click(within(addRow).getAllByRole('button')[1]);
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-030: the item editor commits on the check button, cancels on X and ignores a blank name', async () => {
|
||||
const user = userEvent.setup();
|
||||
let puts = 0;
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.put('/api/admin/packing-templates/1/items/100', () => {
|
||||
puts += 1;
|
||||
return puts === 1
|
||||
? HttpResponse.json({ success: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status: 500 });
|
||||
}),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'T-shirt');
|
||||
|
||||
// A blank name just closes the editor
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const blank = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(blank);
|
||||
await user.click(within(blank.parentElement as HTMLElement).getAllByRole('button')[0]);
|
||||
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// X discards the pending name
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const editing = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(editing);
|
||||
await user.type(editing, 'Discarded');
|
||||
await user.click(within(editing.parentElement as HTMLElement).getAllByRole('button')[1]);
|
||||
await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument());
|
||||
expect(puts).toBe(0);
|
||||
|
||||
// The check button commits
|
||||
await user.click(itemButtons('T-shirt')[0]);
|
||||
const editing2 = screen.getByDisplayValue('T-shirt');
|
||||
await user.clear(editing2);
|
||||
await user.type(editing2, 'Tank Top');
|
||||
await user.click(within(editing2.parentElement as HTMLElement).getAllByRole('button')[0]);
|
||||
await screen.findByText('Tank Top');
|
||||
expect(puts).toBe(1);
|
||||
|
||||
// A failing rename keeps the editor open and toasts
|
||||
await user.click(itemButtons('Tank Top')[0]);
|
||||
const editing3 = screen.getByDisplayValue('Tank Top');
|
||||
await user.clear(editing3);
|
||||
await user.type(editing3, 'Vest{Enter}');
|
||||
expect(await screen.findByText('Failed to save')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Vest')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-031: a failing item delete toasts and keeps the item', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.error()),
|
||||
);
|
||||
render(<><ToastContainer /><PackingTemplateManager /></>);
|
||||
await expandBeachTrip(user, 'T-shirt');
|
||||
|
||||
await user.click(itemButtons('T-shirt')[1]);
|
||||
|
||||
expect(await screen.findByText('Failed to delete item')).toBeInTheDocument();
|
||||
expect(screen.getByText('T-shirt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-PKG-032: the chevron button expands and collapses the template', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
|
||||
http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
|
||||
);
|
||||
render(<PackingTemplateManager />);
|
||||
await screen.findByText('Beach Trip');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[0]);
|
||||
await screen.findByText('Clothing');
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[0]);
|
||||
await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,13 +115,12 @@ export default function PackingTemplateManager() {
|
||||
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
// Item CRUD
|
||||
const handleAddItem = async (catId: number) => {
|
||||
// The name is already guaranteed non-empty by the button and the Enter handler.
|
||||
if (!expandedId) return
|
||||
if (!newItemName.trim() || !expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
@@ -144,7 +143,7 @@ export default function PackingTemplateManager() {
|
||||
try {
|
||||
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||
}
|
||||
|
||||
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||
@@ -158,7 +157,7 @@ export default function PackingTemplateManager() {
|
||||
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => setShowCreate(true)}
|
||||
<button onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||
</button>
|
||||
@@ -171,8 +170,8 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||
<button type="button" onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button type="button" onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -187,7 +186,7 @@ export default function PackingTemplateManager() {
|
||||
<div key={tmpl.id}>
|
||||
{/* Template row */}
|
||||
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||
</button>
|
||||
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||
@@ -197,14 +196,14 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||
) : (
|
||||
<button type="button" onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-left bg-transparent border-0 p-0 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</button>
|
||||
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||
</span>
|
||||
<button type="button" onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||
<button type="button" onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
|
||||
@@ -228,11 +227,11 @@ export default function PackingTemplateManager() {
|
||||
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||
<button type="button" onClick={() => handleDeleteCategory(cat.id)}
|
||||
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -246,15 +245,15 @@ export default function PackingTemplateManager() {
|
||||
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button type="button" onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||
<button type="button" onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||
<button type="button" onClick={() => handleDeleteItem(item.id)}
|
||||
<button onClick={() => handleDeleteItem(item.id)}
|
||||
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||
</>
|
||||
)}
|
||||
@@ -268,9 +267,9 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||
placeholder={t('admin.packingTemplates.itemName')}
|
||||
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||
<button type="button" onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||
</div>
|
||||
)}
|
||||
@@ -287,11 +286,11 @@ export default function PackingTemplateManager() {
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||
placeholder={t('admin.packingTemplates.categoryName')}
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||
<button type="button" onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button type="button" onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => setAddingCategory(true)}
|
||||
<button onClick={() => setAddingCategory(true)}
|
||||
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||
</button>
|
||||
|
||||
@@ -269,6 +269,6 @@ describe('PermissionsPanel', () => {
|
||||
),
|
||||
);
|
||||
renderPanel();
|
||||
expect(await screen.findByText('Error')).toBeInTheDocument();
|
||||
await screen.findByText('Error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<p className="text-xs text-slate-400 mt-0.5">{t('perm.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
title={t('perm.resetDefaults')}
|
||||
@@ -114,7 +114,7 @@ export default function PermissionsPanel(): React.ReactElement {
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">{t('perm.resetDefaults')}</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:bg-slate-400 transition-colors"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,779 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Activity, FolderTree, HardDrive } from 'lucide-react'
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageMigrationStatus,
|
||||
type StorageTestResponse,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { formatBytes } from '../../../utils/formatBytes'
|
||||
import { relativeTime } from '../../../utils/relativeTime'
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
import { useToast } from '../../shared/Toast'
|
||||
import Section from '../../Settings/Section'
|
||||
import BackendForm, { type BackendFormMirrorProps } from './BackendForm'
|
||||
import {
|
||||
CACHE_CATEGORIES,
|
||||
adoptedMirrorFor,
|
||||
computeMigrationCandidates,
|
||||
effectiveCategoryMap,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
settingsDocumentOf,
|
||||
setMirrorTargets,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
type FoldedBackendRow,
|
||||
type MigrationCandidate,
|
||||
} from './storageModel'
|
||||
import { useStorageAdmin } from './useStorageAdmin'
|
||||
|
||||
/** Display-name mapper for joined category lists — the raw id renders only in the badge. */
|
||||
const categoryNames = (t: (key: string) => string, ids: readonly string[]): string =>
|
||||
ids.map((id) => t(`storage.category.${id}`)).join(', ')
|
||||
|
||||
function TestResult({ result, failedLabel, okLabel }: {
|
||||
result: StorageTestResponse | undefined
|
||||
failedLabel: string
|
||||
okLabel: string
|
||||
}): React.ReactElement | null {
|
||||
if (result === undefined) return null
|
||||
return (
|
||||
<div className="mt-2 space-y-0.5">
|
||||
<p className="text-xs font-semibold text-content">{result.ok ? okLabel : failedLabel}</p>
|
||||
{result.targets.map((target) => (
|
||||
<p key={target.name} className="text-xs text-content-faint">
|
||||
{target.ok ? '✓' : '✗'} {target.name}
|
||||
{target.error ? ` — ${target.error}` : ''}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const LINK_BUTTON_STYLE: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer' }
|
||||
|
||||
export default function AdminStoragePanel(): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const admin = useStorageAdmin(t('common.error'), t('storage.saveConflict'))
|
||||
const [editing, setEditing] = useState<{
|
||||
initial: StorageBackend | null
|
||||
originalName: string | null
|
||||
mirror: BackendFormMirrorProps
|
||||
} | null>(null)
|
||||
const [confirmRemove, setConfirmRemove] = useState<{ name: string; degenerate: boolean } | null>(null)
|
||||
const [syncPrompt, setSyncPrompt] = useState<string | null>(null)
|
||||
// Open/closed only — never a snapshot of candidates. Storing the candidate
|
||||
// array here would go stale the moment the operator edits a category while
|
||||
// the dialog is open; the render below and moveAndSave both recompute
|
||||
// computeMigrationCandidates(draft, state) fresh instead.
|
||||
const [migratePromptOpen, setMigratePromptOpen] = useState(false)
|
||||
const [migrationQueue, setMigrationQueue] = useState<MigrationCandidate[]>([])
|
||||
// Set by `save` right before it calls admin.save(): the pre-save mirror
|
||||
// target count per row name. Consumed (and cleared) by the effect below the
|
||||
// first time `admin.state` changes afterward — never touched otherwise, so
|
||||
// unrelated state changes (the backfill poll included) are no-ops here.
|
||||
const pendingPromptCheck = useRef<Map<string, number> | null>(null)
|
||||
// Synchronous single-flight lock for the queue effect below: `admin.state`
|
||||
// only reflects a just-started migration once startMigration's awaited
|
||||
// refreshState() resolves, so `setMigrationQueue(rest)` re-firing the
|
||||
// effect synchronously (still against the pre-POST `admin.state`) would
|
||||
// otherwise dequeue and POST the next candidate before the server has
|
||||
// confirmed the first — a ref (not state) so the guard is visible on that
|
||||
// very next synchronous re-render, not just after a state-driven one.
|
||||
const migrationStartInFlight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPromptCheck.current || !admin.state) return
|
||||
const before = pendingPromptCheck.current
|
||||
pendingPromptCheck.current = null
|
||||
const { rows: afterRows } = foldBackends(admin.state, settingsDocumentOf(admin.state))
|
||||
const grown = afterRows.find((r) => r.mirrorTargets.length > (before.get(r.name) ?? 0))
|
||||
if (grown) setSyncPrompt(grown.name)
|
||||
}, [admin.state])
|
||||
|
||||
// Queued category migrations run strictly sequentially: once a slot opens
|
||||
// (no migration currently running), dequeue the next candidate and start
|
||||
// it. Depends on admin.state so a poll landing a terminal status re-fires
|
||||
// this without any user interaction. The in-flight lock closes the race
|
||||
// where setMigrationQueue(rest) re-fires this effect synchronously while
|
||||
// admin.state still shows the pre-POST world — without it, the next
|
||||
// candidate could dequeue and POST before the first is server-confirmed.
|
||||
// Also waits out a running BACKFILL — the server's one-storage-job-at-a-time
|
||||
// rule spans backfills and migrations alike, so starting while one runs
|
||||
// would 409, and the queued candidate would be lost (no retry).
|
||||
//
|
||||
// A failed startMigration POST would otherwise strand `rest`: it was
|
||||
// already dequeued into local state before the POST, so on failure nothing
|
||||
// ever changes `migrationQueue` or `admin.state` again — the effect never
|
||||
// re-fires and the remaining candidates sit invisibly forever, silently
|
||||
// never migrated. On failure the whole remaining queue is explicitly
|
||||
// cleared (never retried — the operator re-triggers via Save), named in a
|
||||
// toast, and admin.state is refreshed so the panel reflects whatever the
|
||||
// failed attempt's category ended up as server-side.
|
||||
useEffect(() => {
|
||||
if (migrationQueue.length === 0 || !admin.state) return
|
||||
if (migrationStartInFlight.current) return
|
||||
if (admin.state.migrations.some((m) => m.status === 'running')) return
|
||||
if (admin.state.backfills.some((b) => b.status === 'running')) return
|
||||
const [next, ...rest] = migrationQueue
|
||||
migrationStartInFlight.current = true
|
||||
setMigrationQueue(rest)
|
||||
void admin.startMigration(next!.category, next!.toWire).then((error) => {
|
||||
migrationStartInFlight.current = false
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
if (rest.length > 0) {
|
||||
toast.error(t('storage.migrate.queueDropped', { categories: categoryNames(t, rest.map((c) => c.category)) }))
|
||||
setMigrationQueue([])
|
||||
}
|
||||
void admin.refreshState().catch(() => {})
|
||||
}
|
||||
})
|
||||
}, [migrationQueue, admin.state])
|
||||
|
||||
if (admin.loading) {
|
||||
return <p className="text-sm italic p-4 text-content-faint">{t('storage.loading')}</p>
|
||||
}
|
||||
if (!admin.state || !admin.draft) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border p-4 text-sm border-edge bg-surface-card text-content">
|
||||
{admin.loadError || t('common.error')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const { state, draft } = admin
|
||||
|
||||
// Pure/cheap — recomputed every render so the migrate-prompt dialog (below)
|
||||
// never shows a stale candidate set while it's open (fix: migration prompt
|
||||
// staleness). moveAndSave recomputes independently at confirm time rather
|
||||
// than reading this render-scoped value, since the click handler and the
|
||||
// render that produced it are not guaranteed to be the same one.
|
||||
const migrationCandidates = computeMigrationCandidates(draft, state)
|
||||
|
||||
// Duplicate pre-check needs EVERY wire name (hidden mirror names included);
|
||||
// mirror-target candidates are the visible primaries only.
|
||||
const backendNames = [...new Set([...state.backends.map((b) => b.name), ...draft.backends.map((b) => b.name)])]
|
||||
const { rows, degenerate } = foldBackends(state, draft)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const usageSums = usageByBackend(state, draft)
|
||||
|
||||
const startEdit = (row: FoldedBackendRow) => {
|
||||
// Editing a built-in creates a settings override row bearing its name —
|
||||
// the first-class relocation path (merge-by-name).
|
||||
setEditing({
|
||||
initial: row.backend,
|
||||
originalName: row.name,
|
||||
mirror: {
|
||||
candidates: replicaCandidates(rows, row.name, row.mirrorTargets),
|
||||
initialTargets: row.mirrorTargets,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const commitBackend = (backend: StorageBackend, mirrorTargets?: string[]) => {
|
||||
const renamedFrom =
|
||||
editing?.originalName && editing.originalName !== backend.name ? editing.originalName : null
|
||||
// Widened to StorageConfig: these edit helpers are version-blind (they
|
||||
// return plain StorageConfig), and admin.setDraft re-attaches the
|
||||
// draft's own `version` regardless of what shape it's handed.
|
||||
let next: StorageConfig | null = draft
|
||||
if (renamedFrom) next = renameBackendRefs(removeBackend(next, renamedFrom), renamedFrom, backend.name)
|
||||
next = upsertBackend(next, backend)
|
||||
if (mirrorTargets !== undefined) next = setMirrorTargets(state, next, backend.name, mirrorTargets)
|
||||
admin.setDraft(next)
|
||||
setEditing(null)
|
||||
}
|
||||
|
||||
const removeMessage = (name: string, isDegenerate: boolean): string => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
const assigned = isDegenerate
|
||||
? degenerate.find((d) => d.backend.name === name)?.categories ?? []
|
||||
: row?.categories ?? []
|
||||
const usedAsReplicaBy = isDegenerate ? [] : replicaOfPrimaries(draft, name)
|
||||
return [
|
||||
t('storage.remove.body', { name }),
|
||||
assigned.length > 0 ? t('storage.remove.stillAssigned', { categories: categoryNames(t, assigned) }) : '',
|
||||
usedAsReplicaBy.length > 0 ? t('storage.remove.usedAsReplicaBy', { primaries: usedAsReplicaBy.join(', ') }) : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const setCategory = (category: StorageCategory, primaryName: string) => {
|
||||
// Picking a mirrored primary routes through its adopted mirror silently.
|
||||
const target = adoptedMirrorFor(draft, primaryName)?.name ?? primaryName
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const categories = { ...draft.categories }
|
||||
if (stateEntry.source === 'default' && target === stateEntry.backend) {
|
||||
delete categories[category] // back to the default → no longer settings-owned
|
||||
} else {
|
||||
categories[category] = target
|
||||
}
|
||||
admin.setDraft({ ...draft, categories })
|
||||
}
|
||||
|
||||
// Snapshot the LAST-CONFIRMED (pre-save `state`'s own fold, not the
|
||||
// in-progress `draft`) mirror target counts; the effect above compares
|
||||
// them against the fresh post-save state to detect newly-added
|
||||
// replicas. Folding `draft` here would already include the unsaved
|
||||
// edit and mask the very growth this is meant to detect.
|
||||
const snapshotMirrorTargets = (): Map<string, number> => {
|
||||
const { rows: beforeRows } = foldBackends(state, settingsDocumentOf(state))
|
||||
return new Map(beforeRows.map((r) => [r.name, r.mirrorTargets.length]))
|
||||
}
|
||||
|
||||
const doPlainSave = async () => {
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
if (await admin.save()) toast.success(t('storage.saved'))
|
||||
else pendingPromptCheck.current = null
|
||||
}
|
||||
|
||||
// Recomputes the candidate set at confirm time — never trusts whatever was
|
||||
// true when the dialog opened. The operator can edit categories (or a
|
||||
// background poll can land a fresher usage scan) while the dialog is on
|
||||
// screen; the recomputed set is what actually drives both stripCategories
|
||||
// (what gets reverted from the PUT body) and the queue (what gets POSTed),
|
||||
// so the two can never diverge from what the dialog most recently showed.
|
||||
const moveAndSave = async () => {
|
||||
const candidates = computeMigrationCandidates(draft, state)
|
||||
pendingPromptCheck.current = snapshotMirrorTargets()
|
||||
const ok = await admin.save(stripCategories(draft, state, candidates.map((c) => c.category)))
|
||||
if (ok) {
|
||||
setMigrationQueue(candidates)
|
||||
toast.success(t('storage.saved'))
|
||||
} else {
|
||||
pendingPromptCheck.current = null
|
||||
}
|
||||
setMigratePromptOpen(false)
|
||||
}
|
||||
|
||||
const routeOnlySave = async () => {
|
||||
setMigratePromptOpen(false)
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
if (migrationCandidates.length > 0) {
|
||||
setMigratePromptOpen(true)
|
||||
return
|
||||
}
|
||||
await doPlainSave()
|
||||
}
|
||||
|
||||
const handleCancelMigration = async (m: StorageMigrationStatus) => {
|
||||
const error = await admin.cancelMigration(m.category)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const testResultFor = (key: string): StorageTestResponse | 'running' | undefined => admin.testResults[key]
|
||||
|
||||
const handleRefreshStats = async () => {
|
||||
const error = await admin.refreshStats()
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleStartBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
setSyncPrompt((prev) => (prev === row.name ? null : prev))
|
||||
const error = await admin.startBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
const handleCancelBackfill = async (row: FoldedBackendRow) => {
|
||||
if (!row.mirrorName) return
|
||||
const error = await admin.cancelBackfill(row.mirrorName)
|
||||
if (error) toast.error(error)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{state.configError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-xl border p-3 mb-4 text-sm"
|
||||
style={{ borderColor: 'var(--warning)', background: 'var(--warning-soft)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
{t('storage.configError.banner', { error: state.configError })}
|
||||
</div>
|
||||
)}
|
||||
<Section title={t('storage.health.title')} icon={Activity}>
|
||||
{state.health.replicaFailures.length === 0 ? (
|
||||
<p className="text-sm text-content-faint">{t('storage.health.allClear')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{state.health.replicaFailures.map((failure) => (
|
||||
<li key={`${failure.backend}-${failure.key}-${failure.at}`} className="text-sm text-content">
|
||||
{t('storage.health.failureLine', {
|
||||
op: failure.op,
|
||||
key: failure.key,
|
||||
backend: failure.backend,
|
||||
error: failure.error,
|
||||
})}
|
||||
<span className="text-content-faint"> · {relativeTime(failure.at, locale)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{state.seedFilePresent && <p className="text-xs mt-2 text-content-faint">{t('storage.health.seedFile')}</p>}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.backends.title')} icon={HardDrive}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>{t('storage.description')}</p>
|
||||
<p className="text-xs mb-2 text-content-faint">
|
||||
{state.usage
|
||||
? t('storage.usage.computed', { age: relativeTime(state.usage.computedAt, locale) })
|
||||
: t('storage.usage.never')}
|
||||
{' '}
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={handleRefreshStats}>
|
||||
{state.usage ? t('storage.usage.refresh') : t('storage.usage.compute')}
|
||||
</button>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => {
|
||||
const resultKey = row.mirrorName ?? row.name
|
||||
const result = testResultFor(resultKey)
|
||||
// row.mirrorName only exists when foldBackends adopted a draft mirror
|
||||
// for this row, so the draft lookup below cannot miss.
|
||||
const testCandidate = row.mirrorName
|
||||
? draft.backends.find((b) => b.name === row.mirrorName)!
|
||||
: row.backend
|
||||
const rowUsage = usageSums?.[row.name]
|
||||
const backfill = row.mirrorName ? state.backfills.find((b) => b.backend === row.mirrorName) : undefined
|
||||
return (
|
||||
<div key={row.name} data-testid={`storage-backend-${row.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{row.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t(`storage.type.${row.type}`)}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-faint">
|
||||
{t(`storage.source.${row.source}`)}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() =>
|
||||
row.mirrorName
|
||||
? admin.testMirror(resultKey, mirrorProbeTargets(draft, state, testCandidate))
|
||||
: admin.test(testCandidate)
|
||||
}
|
||||
>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
{row.source !== 'env' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => startEdit(row)}>
|
||||
{t('storage.actions.edit')}
|
||||
</button>
|
||||
)}
|
||||
{row.source === 'settings' && (
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: row.name, degenerate: false })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{row.categories.length > 0
|
||||
? t('storage.backends.usedBy', { categories: categoryNames(t, row.categories) })
|
||||
: t('storage.backends.unused')}
|
||||
</p>
|
||||
{rowUsage && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.usage.line', { objects: String(rowUsage.objects), size: formatBytes(rowUsage.bytes) })}
|
||||
{row.name === 'uploads-local' && state.usage!.legacyPhotos.objects > 0
|
||||
? ` (${t('storage.usage.legacyNote')})`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.mirroredTo', { targets: row.mirrorTargets.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.replicaOf.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.replicaOf', { primaries: row.replicaOf.join(', ') })}
|
||||
</p>
|
||||
)}
|
||||
{row.source === 'env' && <p className="text-xs mt-1 text-content-faint">{t('storage.backends.envReadOnly')}</p>}
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
{row.mirrorTargets.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{backfill?.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.running', { done: String(backfill.done), total: String(backfill.total) })}
|
||||
</p>
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.counts', {
|
||||
copied: String(backfill.copied),
|
||||
skipped: String(backfill.skipped),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleCancelBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Terminal statuses render ALONGSIDE the button (not instead-of) —
|
||||
a completed/cancelled/errored backfill must stay re-runnable
|
||||
without a page reload. */}
|
||||
{backfill?.status === 'done' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.done', {
|
||||
copied: String(backfill.copied),
|
||||
deleted: String(backfill.deleted),
|
||||
failed: String(backfill.failed),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{backfill?.status === 'cancelled' && (
|
||||
<p className="text-xs text-content-faint">{t('storage.sync.cancelled')}</p>
|
||||
)}
|
||||
{backfill?.status === 'error' && (
|
||||
<p className="text-xs text-content-faint">
|
||||
{t('storage.sync.error', { error: backfill.error ?? '' })}
|
||||
</p>
|
||||
)}
|
||||
{syncPrompt !== row.name && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{syncPrompt === row.name && (
|
||||
<div className="mt-2 rounded-lg border p-2 border-edge-secondary">
|
||||
<p className="text-xs text-content">{t('storage.sync.prompt')}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => handleStartBackfill(row)}
|
||||
>
|
||||
{t('storage.sync.now')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setSyncPrompt(null)}
|
||||
>
|
||||
{t('storage.sync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{degenerate.map(({ backend, reason }) => {
|
||||
const result = testResultFor(backend.name)
|
||||
const primary = backend.type === 'mirror' ? backend.options.primary : ''
|
||||
return (
|
||||
<div key={backend.name} data-testid={`storage-backend-${backend.name}`} className="rounded-xl border p-4 border-edge-secondary">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-semibold text-content">{backend.name}</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full border border-edge text-content-secondary">
|
||||
{t('storage.type.mirror')}
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => admin.test(backend)}>
|
||||
{t('storage.actions.test')}
|
||||
</button>
|
||||
<button type="button" className="text-xs underline text-content-secondary" style={LINK_BUTTON_STYLE} onClick={() => setConfirmRemove({ name: backend.name, degenerate: true })}>
|
||||
{t('storage.actions.remove')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t(`storage.mirror.degenerate.${reason}`, { primary })}</p>
|
||||
{result === 'running' ? (
|
||||
<p className="text-xs mt-2 text-content-faint">{t('storage.test.running')}</p>
|
||||
) : (
|
||||
<TestResult
|
||||
result={result as StorageTestResponse | undefined}
|
||||
okLabel={t('storage.test.ok')}
|
||||
failedLabel={t('storage.test.failed')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{state.migrations.length > 0 && (
|
||||
<div className="space-y-3 mt-3">
|
||||
{state.migrations.map((m) => (
|
||||
<div
|
||||
key={m.category}
|
||||
data-testid={`storage-migration-${m.category}`}
|
||||
className="rounded-xl border p-4 border-edge-secondary"
|
||||
>
|
||||
<p className="text-sm font-semibold text-content">{t(`storage.category.${m.category}`)}</p>
|
||||
{m.status === 'running' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.running', {
|
||||
category: t(`storage.category.${m.category}`),
|
||||
done: String(m.done),
|
||||
total: String(m.total),
|
||||
})}
|
||||
</p>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void handleCancelMigration(m)}
|
||||
>
|
||||
{t('storage.migrate.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : m.status === 'done' ? (
|
||||
<>
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.done', { copied: String(m.copied), skipped: String(m.skipped) })}
|
||||
</p>
|
||||
{m.failed > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.doneFailures', { failed: String(m.failed) })}
|
||||
</p>
|
||||
)}
|
||||
{m.reclaimable && (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.reclaimable', {
|
||||
objects: String(m.reclaimable.objects),
|
||||
size: formatBytes(m.reclaimable.bytes),
|
||||
from: m.from,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : m.status === 'failed' ? (
|
||||
<p className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.migrate.failed', { error: m.error ?? '' })}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs mt-1 text-content-faint">{t('storage.migrate.cancelled')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<BackendForm
|
||||
initial={editing.initial}
|
||||
backendNames={backendNames}
|
||||
mirror={editing.mirror}
|
||||
onCommit={commitBackend}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
) : (
|
||||
<button type="button"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
initial: null,
|
||||
originalName: null,
|
||||
mirror: { candidates: replicaCandidates(rows, null), initialTargets: [] },
|
||||
})
|
||||
}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.backends.add')}
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title={t('storage.categories.title')} icon={FolderTree}>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-4">
|
||||
{STORAGE_CATEGORIES.map((category) => {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
const stateEntry = state.categories[category]!
|
||||
const selectedPrimary = primaryNameOf(state, draft, effective[category])
|
||||
const changed = selectedPrimary !== primaryNameOf(state, draft, stateEntry.backend)
|
||||
const viaMirror = effective[category] !== selectedPrimary
|
||||
return (
|
||||
<div key={category} data-testid={`storage-category-${category}`}>
|
||||
<label className="block text-sm font-medium mb-1 text-content-secondary">
|
||||
{t(`storage.category.${category}`)}
|
||||
<span className="ml-2 px-1.5 py-0.5 rounded border font-mono font-normal text-xs border-edge text-content-faint">
|
||||
{category}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs mb-1.5 text-content-faint">{t(`storage.categoryDesc.${category}`)}</p>
|
||||
{state.usage?.categories[category] && (
|
||||
<p className="text-xs mb-1.5 text-content-faint">
|
||||
{t('storage.usage.line', {
|
||||
objects: String(state.usage.categories[category]!.objects),
|
||||
size: formatBytes(state.usage.categories[category]!.bytes),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<CustomSelect
|
||||
value={selectedPrimary}
|
||||
onChange={(value) => setCategory(category, String(value))}
|
||||
options={rows.map((row) => ({
|
||||
value: row.name,
|
||||
label:
|
||||
stateEntry.source === 'default' && row.name === stateEntry.backend
|
||||
? `${row.name} (${t('storage.categories.default')})`
|
||||
: row.name,
|
||||
}))}
|
||||
size="sm"
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
{changed && (
|
||||
<p role="alert" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.categories.reassignWarning')}
|
||||
</p>
|
||||
)}
|
||||
{viaMirror && CACHE_CATEGORIES.includes(category) && (
|
||||
<p role="note" className="text-xs mt-1 text-content-faint">
|
||||
{t('storage.mirror.cacheWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={save}
|
||||
disabled={!admin.dirty || admin.saving}
|
||||
style={{
|
||||
padding: '10px 20px', borderRadius: 10, cursor: admin.dirty && !admin.saving ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)', color: 'var(--text-primary)',
|
||||
opacity: admin.dirty && !admin.saving ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.save')}
|
||||
</button>
|
||||
{admin.dirty && <span className="text-xs text-content-faint">{t('storage.unsaved')}</span>}
|
||||
</div>
|
||||
{migrationQueue.length > 0 && (
|
||||
<p className="text-xs mt-2 text-content-faint">
|
||||
{t('storage.migrate.queued', { categories: categoryNames(t, migrationQueue.map((c) => c.category)) })}
|
||||
</p>
|
||||
)}
|
||||
{migratePromptOpen && (
|
||||
<div className="rounded-lg border px-3 py-2 mt-2 border-edge bg-surface-secondary" role="alertdialog">
|
||||
<p className="text-sm text-content">{t('storage.migrate.promptTitle')}</p>
|
||||
{migrationCandidates.map((c) => (
|
||||
<p key={c.category} className="text-xs mt-1 text-content-secondary">
|
||||
{c.objects === null
|
||||
? t('storage.migrate.promptLineUnknown', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})
|
||||
: t('storage.migrate.promptLine', {
|
||||
category: t(`storage.category.${c.category}`),
|
||||
objects: String(c.objects),
|
||||
size: formatBytes(c.bytes ?? 0),
|
||||
from: c.from,
|
||||
to: c.to,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void moveAndSave()}
|
||||
>
|
||||
{t('storage.migrate.move')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void routeOnlySave()}
|
||||
>
|
||||
{t('storage.migrate.routeOnly')}
|
||||
</button>
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => setMigratePromptOpen(false)}
|
||||
>
|
||||
{t('storage.migrate.promptCancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{admin.saveError && (
|
||||
<div role="alert" className="text-sm mt-2 text-content">
|
||||
<p>{admin.saveError}</p>
|
||||
{/* A 409 leaves the draft pinned to a version the server has moved
|
||||
past; "save again" can never clear it, so the banner offers the
|
||||
one action that can — see useStorageAdmin.discardDraft. */}
|
||||
{admin.saveConflict && (
|
||||
<button type="button"
|
||||
className="text-xs underline text-content-secondary mt-1"
|
||||
style={LINK_BUTTON_STYLE}
|
||||
onClick={() => void admin.discardDraft()}
|
||||
>
|
||||
{t('storage.discardAndReload')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={confirmRemove !== null}
|
||||
onClose={() => setConfirmRemove(null)}
|
||||
onConfirm={() => {
|
||||
if (confirmRemove) {
|
||||
admin.setDraft(
|
||||
confirmRemove.degenerate
|
||||
? removeBackend(draft, confirmRemove.name)
|
||||
: removeBackendAndMirrors(state, draft, confirmRemove.name),
|
||||
)
|
||||
}
|
||||
setConfirmRemove(null)
|
||||
}}
|
||||
title={t('storage.remove.title')}
|
||||
message={confirmRemove ? removeMessage(confirmRemove.name, confirmRemove.degenerate) : ''}
|
||||
confirmLabel={t('storage.remove.title')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MASKED_SETTING_VALUE, type StorageBackend } from '@trek/shared';
|
||||
import { fireEvent, render, screen } from '../../../../tests/helpers/render';
|
||||
import BackendForm from './BackendForm';
|
||||
|
||||
const NAMES = ['uploads-local', 'backups-local', 'off-box'];
|
||||
|
||||
type FormProps = Parameters<typeof BackendForm>[0];
|
||||
|
||||
function renderForm(overrides: Partial<FormProps> = {}) {
|
||||
const onCommit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<BackendForm
|
||||
initial={null}
|
||||
backendNames={NAMES}
|
||||
onCommit={onCommit}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
return { onCommit, onCancel };
|
||||
}
|
||||
|
||||
const S3_INITIAL: StorageBackend = {
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
};
|
||||
|
||||
describe('BackendForm', () => {
|
||||
it('FE-ADMIN-STORF-001: local renders the root path field from the registry', () => {
|
||||
renderForm();
|
||||
expect(screen.getByLabelText(/Root directory/)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/Bucket/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-002: s3 renders every field by kind — password secret, number inputs, defaultValue placeholders', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByLabelText(/Endpoint URL/)).toHaveAttribute('type', 'text');
|
||||
expect(screen.getByLabelText(/Secret access key/)).toHaveAttribute('type', 'password');
|
||||
expect(screen.getByLabelText(/Region/)).toHaveAttribute('placeholder', 'us-east-1');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('type', 'number');
|
||||
expect(screen.getByLabelText(/Retries/)).toHaveAttribute('placeholder', '1');
|
||||
expect(screen.getByLabelText(/Timeout \(ms\)/)).toHaveAttribute('placeholder', '30000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-004: the mask echoes back through commit untouched (no-op by contract)', () => {
|
||||
const { onCommit } = renderForm({ initial: S3_INITIAL });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
options: expect.objectContaining({ secretAccessKey: MASKED_SETTING_VALUE, retries: 1, timeoutMs: 30000 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-005: a typed plaintext secret keeps Apply offered (no encryption-key gate)', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk-new' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-006: Apply stays disabled until name and every required field are filled', () => {
|
||||
const { onCommit } = renderForm();
|
||||
const apply = screen.getByRole('button', { name: 'Apply' });
|
||||
expect(apply).toBeDisabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'nas' } });
|
||||
expect(apply).toBeDisabled(); // root still empty
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/mnt/nas' } });
|
||||
expect(apply).toBeEnabled();
|
||||
fireEvent.click(apply);
|
||||
expect(onCommit).toHaveBeenCalledWith({ name: 'nas', type: 'local', options: { root: '/mnt/nas' } });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-007: a duplicate name on a NEW backend warns and blocks Apply', () => {
|
||||
renderForm();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
fireEvent.change(screen.getByLabelText(/Root directory/), { target: { value: '/x' } });
|
||||
expect(screen.getByText(/A backend named off-box already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-008: empty optional fields are omitted so the shared schema defaults apply', () => {
|
||||
const { onCommit } = renderForm();
|
||||
// Switch to s3 via the type select (CustomSelect renders options into a portal).
|
||||
fireEvent.click(screen.getByText('Local'));
|
||||
fireEvent.click(screen.getByText('S3'));
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'fresh' } });
|
||||
fireEvent.change(screen.getByLabelText(/Endpoint URL/), { target: { value: 'http://127.0.0.1:9000' } });
|
||||
fireEvent.change(screen.getByLabelText(/Bucket/), { target: { value: 'trek' } });
|
||||
fireEvent.change(screen.getByLabelText(/Access key ID/), { target: { value: 'ak' } });
|
||||
fireEvent.change(screen.getByLabelText(/Secret access key/), { target: { value: 'sk' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
const committed = onCommit.mock.calls[0]![0] as StorageBackend;
|
||||
expect(committed.options).toEqual({
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: 'sk',
|
||||
});
|
||||
expect(committed.options).not.toHaveProperty('region');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-009: renaming onto another existing backend warns and blocks Apply', () => {
|
||||
renderForm({ initial: S3_INITIAL });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'uploads-local' } });
|
||||
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
||||
// Keeping the original name stays allowed.
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'off-box' } });
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-010: the type select no longer offers Mirror', () => {
|
||||
renderForm();
|
||||
fireEvent.click(screen.getByText('Local')); // open the type select
|
||||
expect(screen.queryByText('Mirror')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('S3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-011: the mirror composer renders candidates minus self and commits targets', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local', 'backups-local', 'off-box'], initialTargets: ['backups-local'] },
|
||||
});
|
||||
expect(screen.getByText('Mirror targets')).toBeInTheDocument();
|
||||
const boxes = screen.getAllByRole('checkbox');
|
||||
expect(boxes).toHaveLength(2); // off-box (self) excluded
|
||||
expect(screen.getByRole('checkbox', { name: 'backups-local' })).toBeChecked();
|
||||
expect(screen.getByRole('note').textContent).toContain('slows every upload');
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'off-box', type: 's3' }),
|
||||
['backups-local', 'uploads-local'],
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORF-012: unchecking every target commits an empty array (dissolve) and hides the latency note', () => {
|
||||
const { onCommit } = renderForm({
|
||||
initial: S3_INITIAL,
|
||||
mirror: { candidates: ['uploads-local'], initialTargets: ['uploads-local'] },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: 'uploads-local' }));
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
||||
expect(onCommit).toHaveBeenCalledWith(expect.objectContaining({ name: 'off-box' }), []);
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
STORAGE_BACKEND_TYPES,
|
||||
STORAGE_BACKEND_TYPE_IDS,
|
||||
type StorageBackend,
|
||||
type StorageBackendFieldDef,
|
||||
type StorageBackendTypeId,
|
||||
} from '@trek/shared'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomSelect from '../../shared/CustomSelect'
|
||||
|
||||
type FieldValues = Record<string, string | string[]>
|
||||
|
||||
function valuesOf(backend: StorageBackend | null): FieldValues {
|
||||
if (!backend) return {}
|
||||
const values: FieldValues = {}
|
||||
for (const [key, value] of Object.entries(backend.options)) {
|
||||
values[key] = Array.isArray(value) ? value : String(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export interface BackendFormMirrorProps {
|
||||
/** Selectable replica targets (the form excludes its own name at render). */
|
||||
candidates: string[]
|
||||
initialTargets: string[]
|
||||
}
|
||||
|
||||
interface BackendFormProps {
|
||||
/** null = new backend */
|
||||
initial: StorageBackend | null
|
||||
/** Every defined backend name — mirror-target options and the duplicate pre-check. */
|
||||
backendNames: string[]
|
||||
/**
|
||||
* Present on non-mirror backends: renders the Mirror-targets composer
|
||||
* (replicas-on-primary — panel-supplied chrome, never a registry field).
|
||||
* When present, onCommit's second argument is always an array.
|
||||
*/
|
||||
mirror?: BackendFormMirrorProps
|
||||
onCommit: (backend: StorageBackend, mirrorTargets?: string[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const LABEL_CLASS = 'block text-sm font-medium mb-1.5 text-content-secondary'
|
||||
const INPUT_CLASS =
|
||||
'mt-1.5 w-full px-3 py-2 border rounded-lg text-sm border-edge bg-surface-card text-content'
|
||||
|
||||
/**
|
||||
* Renders whatever STORAGE_BACKEND_TYPES declares, by field kind. The raw
|
||||
* `mirror` type is hidden from the type select — mirrors are composed via the
|
||||
* Mirror-targets prop block and synthesized by the panels (the ref-kind
|
||||
* renderers below stay for future registry types).
|
||||
*/
|
||||
export default function BackendForm({
|
||||
initial,
|
||||
backendNames,
|
||||
mirror,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: BackendFormProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [type, setType] = useState<StorageBackendTypeId>(initial?.type ?? 'local')
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [values, setValues] = useState<FieldValues>(() => valuesOf(initial))
|
||||
const [targets, setTargets] = useState<string[]>(mirror?.initialTargets ?? [])
|
||||
|
||||
const fields = STORAGE_BACKEND_TYPES[type].fields as readonly StorageBackendFieldDef[]
|
||||
const refOptions = backendNames.filter((candidate) => candidate !== name.trim())
|
||||
const setValue = (key: string, value: string | string[]) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }))
|
||||
|
||||
const filled = (field: StorageBackendFieldDef): boolean => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') return Array.isArray(value) && value.length > 0
|
||||
return typeof value === 'string' && value.trim() !== ''
|
||||
}
|
||||
const duplicate = name.trim() !== (initial?.name ?? '') && backendNames.includes(name.trim())
|
||||
const canApply =
|
||||
name.trim() !== '' && !duplicate && fields.every((f) => !f.required || filled(f))
|
||||
|
||||
const apply = () => {
|
||||
const options: Record<string, unknown> = {}
|
||||
for (const field of fields) {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
options[field.key] = Array.isArray(value) ? value : []
|
||||
continue
|
||||
}
|
||||
const text = typeof value === 'string' ? value : ''
|
||||
if (text === '' && !field.required) continue // omitted → the shared schema default applies
|
||||
options[field.key] = field.kind === 'number' ? Number(text) : text
|
||||
}
|
||||
// The options were built from the same field defs the schema is generated
|
||||
// from — this is the same sanctioned cast storageModel.asWireBackend makes.
|
||||
const payload = { name: name.trim(), type, options } as StorageBackend
|
||||
// Arity matters: the landed tests pin single-argument calls when no mirror
|
||||
// block is supplied (toHaveBeenCalledWith treats a trailing undefined as a
|
||||
// mismatch), so the second argument exists only when the composer does.
|
||||
if (mirror) onCommit(payload, targets)
|
||||
else onCommit(payload)
|
||||
}
|
||||
|
||||
const renderField = (field: StorageBackendFieldDef): React.ReactElement => {
|
||||
const value = values[field.key]
|
||||
if (field.kind === 'backend-ref') {
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<CustomSelect
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(next) => setValue(field.key, String(next))}
|
||||
options={refOptions.map((candidate) => ({ value: candidate, label: candidate }))}
|
||||
placeholder={t(field.labelKey)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (field.kind === 'backend-ref-list') {
|
||||
const selected = Array.isArray(value) ? value : []
|
||||
return (
|
||||
<div key={field.key}>
|
||||
<span className={LABEL_CLASS}>{t(field.labelKey)}</span>
|
||||
<div className="space-y-1">
|
||||
{refOptions.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setValue(
|
||||
field.key,
|
||||
e.target.checked
|
||||
? [...selected, candidate]
|
||||
: selected.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const inputType = field.kind === 'secret' ? 'password' : field.kind === 'number' ? 'number' : 'text'
|
||||
return (
|
||||
<label key={field.key} className={LABEL_CLASS}>
|
||||
{t(field.labelKey)}
|
||||
<input
|
||||
type={inputType}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
onChange={(e) => setValue(field.key, e.target.value)}
|
||||
placeholder={field.defaultValue !== undefined ? String(field.defaultValue) : ''}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
{field.helpKey && <span className="block text-xs mt-1 font-normal text-content-faint">{t(field.helpKey)}</span>}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border p-4 space-y-4 border-edge bg-surface-card">
|
||||
<p className="text-sm font-semibold text-content">
|
||||
{initial ? t('storage.form.editTitle') : t('storage.form.addTitle')}
|
||||
</p>
|
||||
|
||||
<label className={LABEL_CLASS}>
|
||||
{t('storage.form.name')}
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</label>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-content-faint" role="alert">
|
||||
{t('storage.form.duplicateName', { name: name.trim() })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.form.type')}</span>
|
||||
<CustomSelect
|
||||
value={type}
|
||||
onChange={(next) => {
|
||||
setType(next as StorageBackendTypeId)
|
||||
setValues({}) // a different type has different fields
|
||||
}}
|
||||
options={STORAGE_BACKEND_TYPE_IDS.filter((id) => id !== 'mirror').map((id) => ({
|
||||
value: id,
|
||||
label: t(`storage.type.${id}`),
|
||||
}))}
|
||||
size="sm"
|
||||
disabled={initial !== null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{fields.map(renderField)}
|
||||
|
||||
{mirror && (
|
||||
<div>
|
||||
<span className={LABEL_CLASS}>{t('storage.mirror.targets')}</span>
|
||||
<p className="text-xs mb-1 text-content-faint">{t('storage.mirror.targetsHelp')}</p>
|
||||
<div className="space-y-1">
|
||||
{mirror.candidates
|
||||
.filter((candidate) => candidate !== name.trim())
|
||||
.map((candidate) => (
|
||||
<label key={candidate} className="flex items-center gap-2 text-sm text-content">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targets.includes(candidate)}
|
||||
onChange={(e) =>
|
||||
setTargets(
|
||||
e.target.checked
|
||||
? [...targets, candidate]
|
||||
: targets.filter((existing) => existing !== candidate),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{candidate}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{targets.length > 0 && (
|
||||
<p className="text-xs mt-1 text-content-faint" role="note">
|
||||
{t('storage.mirror.latencyNote')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button"
|
||||
onClick={apply}
|
||||
disabled={!canApply}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: canApply ? 'pointer' : 'default',
|
||||
fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600,
|
||||
border: '2px solid var(--text-primary)', background: 'var(--bg-hover)',
|
||||
color: 'var(--text-primary)', opacity: canApply ? 1 : 0.5,
|
||||
}}
|
||||
>
|
||||
{t('storage.form.apply')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '8px 20px', borderRadius: 10, cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
border: '2px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{t('storage.form.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
MASKED_SETTING_VALUE,
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
} from '@trek/shared';
|
||||
import {
|
||||
categoriesPointingAt,
|
||||
computeMigrationCandidates,
|
||||
foldBackends,
|
||||
mirrorProbeTargets,
|
||||
mirrorsReferencing,
|
||||
primaryNameOf,
|
||||
removeBackend,
|
||||
removeBackendAndMirrors,
|
||||
renameBackendRefs,
|
||||
replicaCandidates,
|
||||
replicaOfPrimaries,
|
||||
setMirrorTargets,
|
||||
settingsDocumentOf,
|
||||
stripCategories,
|
||||
upsertBackend,
|
||||
usageByBackend,
|
||||
} from './storageModel';
|
||||
|
||||
const STATE: StorageAdminState = {
|
||||
backends: [
|
||||
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] },
|
||||
{ name: 'place-photos-local', type: 'local', source: 'env', options: { root: '/photos' }, categories: ['places'] },
|
||||
{
|
||||
name: 'off-box',
|
||||
type: 's3',
|
||||
source: 'settings',
|
||||
options: {
|
||||
endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak',
|
||||
secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000,
|
||||
},
|
||||
categories: ['covers'],
|
||||
},
|
||||
],
|
||||
categories: {
|
||||
files: { backend: 'uploads-local', source: 'default' },
|
||||
journey: { backend: 'uploads-local', source: 'default' },
|
||||
covers: { backend: 'off-box', source: 'settings' },
|
||||
avatars: { backend: 'uploads-local', source: 'default' },
|
||||
places: { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-google': { backend: 'place-photos-local', source: 'default' },
|
||||
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
||||
backups: { backend: 'backups-local', source: 'default' },
|
||||
},
|
||||
health: { replicaFailures: [] },
|
||||
seedFilePresent: false,
|
||||
usage: null,
|
||||
backfills: [],
|
||||
migrations: [],
|
||||
version: 5,
|
||||
configError: null,
|
||||
};
|
||||
|
||||
describe('settingsDocumentOf', () => {
|
||||
it('FE-ADMIN-STORM-001: carries ONLY settings-sourced backends and categories (the PUT contract)', () => {
|
||||
const doc = settingsDocumentOf(STATE);
|
||||
expect(doc.backends.map((b) => b.name)).toEqual(['off-box']);
|
||||
expect(doc.categories).toEqual({ covers: 'off-box' });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-001b: carries version from state (audit #7 — the PUT body\'s optimistic-concurrency check)', () => {
|
||||
expect(settingsDocumentOf(STATE).version).toBe(5);
|
||||
expect(settingsDocumentOf({ ...STATE, version: 12 }).version).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove pre-check helpers', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'mir', type: 'mirror', options: { primary: 'backups-local', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas', backups: 'mir' },
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-002: lists categories pointing at a backend', () => {
|
||||
expect(categoriesPointingAt(draft, 'nas')).toEqual(['covers']);
|
||||
expect(categoriesPointingAt(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-003: lists mirrors referencing a backend (primary or replica)', () => {
|
||||
expect(mirrorsReferencing(draft, 'nas')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'backups-local')).toEqual(['mir']);
|
||||
expect(mirrorsReferencing(draft, 'unused')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('draft edits', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } }],
|
||||
categories: {},
|
||||
};
|
||||
|
||||
it('FE-ADMIN-STORM-005: upsert replaces by name in place, appends new ones, never mutates', () => {
|
||||
const edited = upsertBackend(draft, { name: 'nas', type: 'local', options: { root: '/mnt/nas2' } });
|
||||
expect(edited.backends).toEqual([{ name: 'nas', type: 'local', options: { root: '/mnt/nas2' } }]);
|
||||
const added = upsertBackend(draft, { name: 'other', type: 'local', options: { root: '/o' } });
|
||||
expect(added.backends.map((b) => b.name)).toEqual(['nas', 'other']);
|
||||
expect(draft.backends).toHaveLength(1); // untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-006: removeBackend filters by name', () => {
|
||||
expect(removeBackend(draft, 'nas').backends).toEqual([]);
|
||||
expect(removeBackend(draft, 'ghost').backends).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Hoisted to module scope (not just the mirror-fold describe below) so the
|
||||
// usageByBackend suite can reuse the same mirrored fixture the brief's test
|
||||
// snippet assumes is in scope.
|
||||
const MIRRORED_STATE: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{
|
||||
name: 'mirror', type: 'mirror', source: 'settings',
|
||||
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
||||
},
|
||||
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: [] },
|
||||
],
|
||||
categories: { ...STATE.categories, backups: { backend: 'mirror', source: 'settings' } },
|
||||
};
|
||||
const mirroredDraft = (): StorageConfig => settingsDocumentOf(MIRRORED_STATE);
|
||||
|
||||
describe('mirror fold/synthesize (replicas-on-primary)', () => {
|
||||
it('FE-ADMIN-STORM-007: foldBackends hides the mirror, decorates primary and replica, unions categories', () => {
|
||||
const { rows, degenerate } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(degenerate).toEqual([]);
|
||||
expect(rows.map((r) => r.name)).not.toContain('mirror');
|
||||
const primary = rows.find((r) => r.name === 'backups-local')!;
|
||||
expect(primary.mirrorTargets).toEqual(['off-box']);
|
||||
expect(primary.mirrorName).toBe('mirror');
|
||||
expect(primary.categories).toContain('backups'); // via the mirror
|
||||
const replica = rows.find((r) => r.name === 'off-box')!;
|
||||
expect(replica.replicaOf).toEqual(['backups-local']);
|
||||
expect(replica.categories).toContain('covers'); // its own direct assignment survives
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-008: setMirrorTargets synthesizes a mirror and reroutes ALL effective categories of the primary, defaults included', () => {
|
||||
const draft = settingsDocumentOf(STATE); // no mirror yet; backups is default → backups-local
|
||||
const next = setMirrorTargets(STATE, draft, 'backups-local', ['off-box']);
|
||||
const mirror = next.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.name).toBe('backups-local-mirror');
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['off-box'] });
|
||||
expect(next.categories.backups).toBe('backups-local-mirror'); // default-sourced category rewritten
|
||||
expect(next.categories.covers).toBe('off-box'); // categories of OTHER backends untouched
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-009: setMirrorTargets adopts a foreign-named mirror in place', () => {
|
||||
const next = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const mirrors = next.backends.filter((b) => b.type === 'mirror');
|
||||
expect(mirrors).toHaveLength(1);
|
||||
expect(mirrors[0]!.name).toBe('mirror'); // adopted, not renamed
|
||||
expect(mirrors[0]!.options).toEqual({ primary: 'backups-local', replicas: ['off-box', 'uploads-local'] });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-010: empty targets dissolve the mirror and revert default-sourced categories', () => {
|
||||
// Build the synthesized state locally: default backups routed via a fresh mirror…
|
||||
const withMirror = setMirrorTargets(STATE, settingsDocumentOf(STATE), 'backups-local', ['off-box']);
|
||||
// …then dissolve it: the backups entry must DROP (state says its default is backups-local).
|
||||
const dissolved = setMirrorTargets(STATE, withMirror, 'backups-local', []);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories).not.toHaveProperty('backups');
|
||||
// Against MIRRORED_STATE (backups already settings-sourced at the mirror), dissolve re-points instead of dropping.
|
||||
const repointed = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', []);
|
||||
expect(repointed.categories.backups).toBe('backups-local');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-011: degenerate mirrors are classified, never hidden', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'off-box', type: 's3', options: STATE.backends[2]!.options } as StorageBackend,
|
||||
{ name: 'm1', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm2', type: 'mirror', options: { primary: 'uploads-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-env', type: 'mirror', options: { primary: 'place-photos-local', replicas: ['off-box'] } },
|
||||
{ name: 'm-ghost', type: 'mirror', options: { primary: 'nope', replicas: [] } },
|
||||
],
|
||||
categories: {},
|
||||
};
|
||||
const { rows, degenerate } = foldBackends(STATE, draft);
|
||||
expect(rows.find((r) => r.name === 'uploads-local')!.mirrorName).toBe('m1'); // first wins
|
||||
expect(degenerate.map((d) => [d.backend.name, d.reason])).toEqual([
|
||||
['m2', 'duplicate-mirror'],
|
||||
['m-env', 'env-primary'],
|
||||
['m-ghost', 'missing-primary'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-012: renameBackendRefs rewrites mirror primaries, replicas, and category entries', () => {
|
||||
const draft: StorageConfig = {
|
||||
backends: [
|
||||
{ name: 'nas', type: 'local', options: { root: '/mnt/nas' } },
|
||||
{ name: 'm', type: 'mirror', options: { primary: 'nas', replicas: ['nas'] } },
|
||||
],
|
||||
categories: { covers: 'nas' },
|
||||
};
|
||||
const renamed = renameBackendRefs(draft, 'nas', 'nas2');
|
||||
const mirror = renamed.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'nas2', replicas: ['nas2'] });
|
||||
expect(renamed.categories.covers).toBe('nas2');
|
||||
expect(renamed.backends[0]!.name).toBe('nas'); // the backend row itself is the caller's job
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-013: removeBackendAndMirrors cascades; replicaOfPrimaries phrases in primary names', () => {
|
||||
const draft = mirroredDraft();
|
||||
expect(replicaOfPrimaries(draft, 'off-box')).toEqual(['backups-local']);
|
||||
const removed = removeBackendAndMirrors(MIRRORED_STATE, draft, 'backups-local');
|
||||
expect(removed.backends.some((b) => b.name === 'mirror')).toBe(false);
|
||||
expect(removed.backends.some((b) => b.name === 'backups-local')).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-015: removing a replica strips it from remaining mirrors; a mirror stripped empty dissolves', () => {
|
||||
// Single-replica mirror: stripping off-box empties it → dissolve + re-point
|
||||
// (backups is settings-sourced at the mirror in MIRRORED_STATE, so it re-points, not drops).
|
||||
const dissolved = removeBackendAndMirrors(MIRRORED_STATE, mirroredDraft(), 'off-box');
|
||||
expect(dissolved.backends.some((b) => b.name === 'off-box')).toBe(false);
|
||||
expect(dissolved.backends.some((b) => b.type === 'mirror')).toBe(false);
|
||||
expect(dissolved.categories.backups).toBe('backups-local');
|
||||
// Multi-replica mirror: stripping one target keeps the mirror alive with the rest.
|
||||
const twoTargets = setMirrorTargets(MIRRORED_STATE, mirroredDraft(), 'backups-local', ['off-box', 'uploads-local']);
|
||||
const survived = removeBackendAndMirrors(MIRRORED_STATE, twoTargets, 'off-box');
|
||||
const mirror = survived.backends.find((b) => b.type === 'mirror')!;
|
||||
expect(mirror.options).toEqual({ primary: 'backups-local', replicas: ['uploads-local'] });
|
||||
expect(survived.categories.backups).toBe('mirror'); // still routed via the surviving mirror
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-014: primaryNameOf maps mirror names (draft or state) to their primary; others pass through', () => {
|
||||
expect(primaryNameOf(MIRRORED_STATE, mirroredDraft(), 'mirror')).toBe('backups-local');
|
||||
expect(primaryNameOf(MIRRORED_STATE, { backends: [], categories: {} }, 'mirror')).toBe('backups-local'); // state fallback
|
||||
expect(primaryNameOf(STATE, settingsDocumentOf(STATE), 'uploads-local')).toBe('uploads-local');
|
||||
});
|
||||
});
|
||||
|
||||
describe('replicaCandidates', () => {
|
||||
it('FE-ADMIN-STORM-020: offers only rows that serve no category, never the row being edited', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
// uploads-local/place-photos-local serve categories directly;
|
||||
// backups-local serves backups through the mirror it is the primary of;
|
||||
// off-box carries a direct `covers` assignment. Nothing is offerable.
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: keeps an already-selected target listed so it can be unchecked', () => {
|
||||
const { rows } = foldBackends(MIRRORED_STATE, mirroredDraft());
|
||||
expect(replicaCandidates(rows, 'backups-local', ['off-box'])).toEqual(['off-box']);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: an unassigned row is offered', () => {
|
||||
const state: StorageAdminState = {
|
||||
...MIRRORED_STATE,
|
||||
backends: [
|
||||
...MIRRORED_STATE.backends,
|
||||
{ name: 'cold-store', type: 'local', source: 'settings', options: { root: '/data/cold' }, categories: [] },
|
||||
],
|
||||
};
|
||||
const { rows } = foldBackends(state, settingsDocumentOf(state));
|
||||
expect(replicaCandidates(rows, 'backups-local')).toEqual(['cold-store']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('usageByBackend', () => {
|
||||
it('FE-ADMIN-STORM-016: sums categories onto their effective backend rows, mirrors onto the primary, legacy onto uploads-local', () => {
|
||||
const usage = {
|
||||
computedAt: 1,
|
||||
categories: Object.fromEntries(
|
||||
STORAGE_CATEGORIES.map((c) => [c, { objects: 1, bytes: 10 }]),
|
||||
) as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 5, bytes: 50 },
|
||||
};
|
||||
const state = { ...MIRRORED_STATE, usage };
|
||||
const sums = usageByBackend(state, mirroredDraft())!;
|
||||
// backups routes via the mirror → attributed to backups-local (the primary row).
|
||||
expect(sums['backups-local']).toEqual({ objects: 1, bytes: 10 });
|
||||
// uploads-local serves 4 direct categories (files, journey, avatars, photos-trek —
|
||||
// this fixture's `places` and `photos-google` default to the env-sourced
|
||||
// place-photos-local backend, not uploads-local) + legacy photos.
|
||||
expect(sums['uploads-local']).toEqual({ objects: 4 + 5, bytes: 40 + 50 });
|
||||
// places + photos-google both default to place-photos-local.
|
||||
expect(sums['place-photos-local']).toEqual({ objects: 2, bytes: 20 });
|
||||
// covers routes to off-box directly.
|
||||
expect(sums['off-box']).toEqual({ objects: 1, bytes: 10 });
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-017: null usage yields null', () => {
|
||||
expect(usageByBackend({ ...STATE, usage: null }, settingsDocumentOf(STATE))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeMigrationCandidates', () => {
|
||||
it('FE-ADMIN-STORM-018: lists changed categories with usage counts; zero-object ones are excluded; null usage keeps them with null counts', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: {
|
||||
files: { objects: 3, bytes: 300 },
|
||||
journey: { objects: 0, bytes: 0 },
|
||||
} as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', journey: 'off-box', avatars: 'off-box' },
|
||||
};
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: 3, bytes: 300 },
|
||||
{ category: 'avatars', from: 'uploads-local', to: 'off-box', toWire: 'off-box', objects: null, bytes: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-018b: reassigning onto a mirrored primary yields toWire = the mirror wire name while `to` stays the display primary', () => {
|
||||
const state: StorageAdminState = {
|
||||
...STATE,
|
||||
backends: [
|
||||
...STATE.backends,
|
||||
{ name: 'nas', type: 'local', source: 'settings', options: { root: '/mnt/nas' }, categories: [] },
|
||||
{
|
||||
name: 'off-box-mirror',
|
||||
type: 'mirror',
|
||||
source: 'settings',
|
||||
options: { primary: 'off-box', replicas: ['nas'] },
|
||||
categories: [],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
computedAt: 1,
|
||||
categories: { files: { objects: 3, bytes: 300 } } as Record<StorageCategory, { objects: number; bytes: number }>,
|
||||
legacyPhotos: { objects: 0, bytes: 0 },
|
||||
},
|
||||
};
|
||||
const base = settingsDocumentOf(state);
|
||||
// Reassign 'files' to the mirrored primary's WIRE name — the same value
|
||||
// setCategory() writes into the draft when the operator picks a mirrored
|
||||
// primary in the UI (adoptedMirrorFor(draft, 'off-box')?.name).
|
||||
const draft: StorageConfig = { ...base, categories: { ...base.categories, files: 'off-box-mirror' } };
|
||||
const candidates = computeMigrationCandidates(draft, state);
|
||||
expect(candidates).toEqual([
|
||||
{ category: 'files', from: 'uploads-local', to: 'off-box', toWire: 'off-box-mirror', objects: 3, bytes: 300 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-019: stripCategories restores the saved assignment for exactly the named categories', () => {
|
||||
const base = settingsDocumentOf(STATE);
|
||||
const draft: StorageConfig = {
|
||||
...base,
|
||||
categories: { ...base.categories, files: 'off-box', covers: 'uploads-local' },
|
||||
};
|
||||
|
||||
const strippedFiles = stripCategories(draft, STATE, ['files']);
|
||||
expect(strippedFiles.categories.files).toBeUndefined(); // default-sourced: saved value is "no override"
|
||||
expect(strippedFiles.categories.covers).toBe('uploads-local'); // untouched — not in the strip list
|
||||
|
||||
const strippedCovers = stripCategories(draft, STATE, ['covers']);
|
||||
expect(strippedCovers.categories.covers).toBe('off-box'); // settings-sourced saved value restored explicitly
|
||||
expect(strippedCovers.categories.files).toBe('off-box'); // untouched — not in the strip list
|
||||
});
|
||||
});
|
||||
|
||||
describe('mirrorProbeTargets', () => {
|
||||
it('FE-ADMIN-STORM-020: a non-mirror candidate passes through unchanged', () => {
|
||||
const local: StorageBackend = { name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } };
|
||||
expect(mirrorProbeTargets(settingsDocumentOf(STATE), STATE, local)).toEqual([local]);
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-021: expands primary + replicas — draft override wins per name, falling back to state for names the draft never touched', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['off-box'] },
|
||||
};
|
||||
// The draft carries an EDITED off-box (unsaved endpoint change) but never
|
||||
// touches uploads-local (a built-in, never draft-owned).
|
||||
const editedOffBox = {
|
||||
name: 'off-box', type: 's3',
|
||||
options: { ...(STATE.backends[2]!.options as Record<string, unknown>), endpoint: 'http://edited:9000' },
|
||||
} as StorageBackend;
|
||||
const draft: StorageConfig = { backends: [editedOffBox, mirror], categories: {} };
|
||||
|
||||
const targets = mirrorProbeTargets(draft, STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local', 'off-box']);
|
||||
// Primary: no draft entry — resolved from state (the live built-in).
|
||||
expect(targets[0]).toEqual({ name: 'uploads-local', type: 'local', options: { root: '/data/uploads' } });
|
||||
// Replica: draft override wins — the unsaved edit, not the saved options.
|
||||
expect((targets[1]!.options as Record<string, unknown>).endpoint).toBe('http://edited:9000');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-STORM-022: a name resolving to neither draft nor state is dropped, not thrown', () => {
|
||||
const mirror: StorageBackend = {
|
||||
name: 'mirror', type: 'mirror',
|
||||
options: { primary: 'uploads-local', replicas: ['ghost'] },
|
||||
};
|
||||
const targets = mirrorProbeTargets(settingsDocumentOf(STATE), STATE, mirror);
|
||||
expect(targets.map((t) => t.name)).toEqual(['uploads-local']);
|
||||
});
|
||||
});
|
||||
@@ -1,440 +0,0 @@
|
||||
import {
|
||||
STORAGE_CATEGORIES,
|
||||
type StorageAdminState,
|
||||
type StorageBackend,
|
||||
type StorageCategory,
|
||||
type StorageConfig,
|
||||
type StorageConfigPut,
|
||||
} from '@trek/shared'
|
||||
|
||||
/** One entry of the effective world (GET state), as the panels render it. */
|
||||
export type StateBackend = StorageAdminState['backends'][number]
|
||||
|
||||
/**
|
||||
* The settings-owned document the PUT carries: settings-sourced backends and
|
||||
* settings-sourced category assignments ONLY — built-ins and env backends are
|
||||
* never in the body unless the operator overrides one by name (in which case
|
||||
* the override already reports source 'settings'). Carries `version` from
|
||||
* the state it was built at (audit #7 — optimistic concurrency): the hook
|
||||
* re-attaches this same version on every subsequent local edit, so a PUT
|
||||
* always submits the version the operator's form was loaded at, not
|
||||
* whatever the server currently holds.
|
||||
*/
|
||||
export function settingsDocumentOf(state: StorageAdminState): StorageConfigPut {
|
||||
return {
|
||||
backends: state.backends.filter((b) => b.source === 'settings').map(asWireBackend),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(state.categories)
|
||||
.filter(([, entry]) => entry.source === 'settings')
|
||||
.map(([category, entry]) => [category, entry.backend]),
|
||||
) as StorageConfig['categories'],
|
||||
version: state.version,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State options are already schema-shaped (the wire contract guarantees it;
|
||||
* secret fields carry the mask, which the server unmasks by name on PUT/test) —
|
||||
* the cast re-attaches the discriminated-union type the loose state record dropped.
|
||||
*/
|
||||
export function asWireBackend(backend: Pick<StateBackend, 'name' | 'type' | 'options'>): StorageBackend {
|
||||
return { name: backend.name, type: backend.type, options: backend.options } as StorageBackend
|
||||
}
|
||||
|
||||
/** Draft categories pointing at a backend name (the friendly remove pre-check). */
|
||||
export function categoriesPointingAt(draft: StorageConfig, name: string): StorageCategory[] {
|
||||
return (Object.entries(draft.categories) as Array<[StorageCategory, string]>)
|
||||
.filter(([, backend]) => backend === name)
|
||||
.map(([category]) => category)
|
||||
}
|
||||
|
||||
/** Draft mirrors referencing a backend name as primary or replica (remove pre-check). */
|
||||
export function mirrorsReferencing(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter((b) => b.type === 'mirror' && (b.options.primary === name || b.options.replicas.includes(name)))
|
||||
.map((b) => b.name)
|
||||
}
|
||||
|
||||
/** Replace by name in place, or append. Never mutates. */
|
||||
export function upsertBackend(draft: StorageConfig, backend: StorageBackend): StorageConfig {
|
||||
const exists = draft.backends.some((b) => b.name === backend.name)
|
||||
return {
|
||||
...draft,
|
||||
backends: exists
|
||||
? draft.backends.map((b) => (b.name === backend.name ? backend : b))
|
||||
: [...draft.backends, backend],
|
||||
}
|
||||
}
|
||||
|
||||
export function removeBackend(draft: StorageConfig, name: string): StorageConfig {
|
||||
return { ...draft, backends: draft.backends.filter((b) => b.name !== name) }
|
||||
}
|
||||
|
||||
// ── Mirror fold/synthesize (replicas-on-primary spec) ─────────────────────────
|
||||
// The draft stays the wire document; folding is a view rule, synthesis a set
|
||||
// of pure draft operations. Mirror NAMES never reach the UI — every helper
|
||||
// that renders phrases mirrors as their primary.
|
||||
|
||||
type MirrorBackend = Extract<StorageBackend, { type: 'mirror' }>
|
||||
const isMirror = (b: StorageBackend): b is MirrorBackend => b.type === 'mirror'
|
||||
|
||||
export interface FoldedBackendRow {
|
||||
name: string
|
||||
type: 'local' | 's3'
|
||||
source: 'built-in' | 'env' | 'settings'
|
||||
backend: StorageBackend
|
||||
/** Direct assignments ∪ assignments routed via the adopted mirror. */
|
||||
categories: StorageCategory[]
|
||||
/** Adopted mirror's replicas — [] when unmirrored. */
|
||||
mirrorTargets: string[]
|
||||
/** Adopted mirror's wire name — needed by draft ops, hidden from the UI. */
|
||||
mirrorName: string | null
|
||||
/** Primaries whose adopted mirror lists this backend as a replica. */
|
||||
replicaOf: string[]
|
||||
}
|
||||
|
||||
export interface DegenerateMirror {
|
||||
backend: StorageBackend
|
||||
reason: 'duplicate-mirror' | 'env-primary' | 'missing-primary'
|
||||
categories: StorageCategory[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetchable content — replicating it is usually wasteful. photos-google is
|
||||
* the Google photo cache (place-photo-cache.service.ts); places is mostly
|
||||
* provider-derived imagery.
|
||||
*/
|
||||
export const CACHE_CATEGORIES: readonly StorageCategory[] = ['photos-google', 'places']
|
||||
|
||||
/** The configurable categories resolved draft-over-state (draft entries win; defaults fill the rest). */
|
||||
export function effectiveCategoryMap(state: StorageAdminState, draft: StorageConfig): Record<StorageCategory, string> {
|
||||
const map = {} as Record<StorageCategory, string>
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
// The admin state's category record is exhaustive by schema contract.
|
||||
map[category] = draft.categories[category] ?? state.categories[category]!.backend
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** First draft mirror wrapping the named primary — the one the UI manages. */
|
||||
export function adoptedMirrorFor(draft: StorageConfig, primaryName: string): MirrorBackend | undefined {
|
||||
return draft.backends.filter(isMirror).find((m) => m.options.primary === primaryName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side mirror expansion for the test probe (audit fix — the server's
|
||||
* `testBackend` resolves a mirror candidate's primary/replicas against its
|
||||
* OWN live snapshot by name, so probing a mirrored row with unsaved draft
|
||||
* edits to those targets would silently test the SAVED options instead of
|
||||
* what's on the form). Draft overrides win per name, falling back to state
|
||||
* (built-in/env); a name that resolves to neither is dropped — that
|
||||
* primary/replica no longer exists, and the row already renders as
|
||||
* degenerate in that case.
|
||||
*/
|
||||
export function mirrorProbeTargets(
|
||||
draft: StorageConfig,
|
||||
state: StorageAdminState,
|
||||
mirror: StorageBackend,
|
||||
): StorageBackend[] {
|
||||
if (mirror.type !== 'mirror') return [mirror]
|
||||
const names = [mirror.options.primary, ...mirror.options.replicas]
|
||||
return names
|
||||
.map((name): StorageBackend | null => {
|
||||
const draftMatch = draft.backends.find((b) => b.name === name)
|
||||
if (draftMatch) return draftMatch
|
||||
const stateMatch = state.backends.find((b) => b.name === name)
|
||||
return stateMatch ? asWireBackend(stateMatch) : null
|
||||
})
|
||||
.filter((b): b is StorageBackend => b !== null)
|
||||
}
|
||||
|
||||
export function foldBackends(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): { rows: FoldedBackendRow[]; degenerate: DegenerateMirror[] } {
|
||||
const draftNames = draft.backends.map((b) => b.name)
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const categoriesOf = (name: string): StorageCategory[] =>
|
||||
STORAGE_CATEGORIES.filter((category) => effective[category] === name)
|
||||
|
||||
// Base (non-mirror) rows, slice-2 precedence: built-ins/env from the state
|
||||
// unless a draft row overrides the name; settings rows from the draft.
|
||||
const base: Array<Pick<FoldedBackendRow, 'name' | 'type' | 'source' | 'backend'>> = []
|
||||
for (const b of state.backends) {
|
||||
if (b.type === 'mirror') continue // mirrors fold below; the draft owns them
|
||||
if (draftNames.includes(b.name)) continue
|
||||
if (b.source === 'settings') continue // removed from the draft, pending save
|
||||
base.push({ name: b.name, type: b.type, source: b.source, backend: asWireBackend(b) })
|
||||
}
|
||||
for (const b of draft.backends) {
|
||||
if (isMirror(b)) continue
|
||||
base.push({ name: b.name, type: b.type, source: 'settings', backend: b })
|
||||
}
|
||||
|
||||
// Adoption: the first mirror per editable primary; the rest are degenerate —
|
||||
// rendered unfolded with a reason, never silently dropped.
|
||||
const adopted = new Map<string, MirrorBackend>()
|
||||
const degenerate: DegenerateMirror[] = []
|
||||
for (const mirror of draft.backends.filter(isMirror)) {
|
||||
const primary = base.find((row) => row.name === mirror.options.primary)
|
||||
if (!primary) degenerate.push({ backend: mirror, reason: 'missing-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (primary.source === 'env') degenerate.push({ backend: mirror, reason: 'env-primary', categories: categoriesOf(mirror.name) })
|
||||
else if (adopted.has(primary.name)) degenerate.push({ backend: mirror, reason: 'duplicate-mirror', categories: categoriesOf(mirror.name) })
|
||||
else adopted.set(primary.name, mirror)
|
||||
}
|
||||
|
||||
const rows = base.map((row): FoldedBackendRow => {
|
||||
const mirror = adopted.get(row.name)
|
||||
return {
|
||||
...row,
|
||||
categories: [...categoriesOf(row.name), ...(mirror ? categoriesOf(mirror.name) : [])],
|
||||
mirrorTargets: mirror ? [...mirror.options.replicas] : [],
|
||||
mirrorName: mirror?.name ?? null,
|
||||
replicaOf: [...adopted.values()]
|
||||
.filter((m) => m.options.replicas.includes(row.name))
|
||||
.map((m) => m.options.primary),
|
||||
}
|
||||
})
|
||||
return { rows, degenerate }
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows offerable as mirror replicas (the "Mirror targets" picker).
|
||||
*
|
||||
* A backend that already serves a category — directly, or as the primary of a
|
||||
* mirror a category routes to, which is exactly what `row.categories` unions —
|
||||
* can never also be a replica: the mirror's sync sweep makes each replica
|
||||
* MATCH the primary, deleting whatever the primary doesn't hold, and `backups`
|
||||
* sweeps the replica's entire root. The server refuses such a config outright
|
||||
* (storage-registry.service.ts, assertNoSharedReplicas); the picker simply
|
||||
* never offers one, so the operator can't walk into the refusal.
|
||||
*
|
||||
* Targets already selected on this mirror stay listed regardless — they must
|
||||
* remain visible to be unchecked (and a config that predates this rule is
|
||||
* repaired here, not hidden).
|
||||
*/
|
||||
export function replicaCandidates(
|
||||
rows: FoldedBackendRow[],
|
||||
selfName: string | null,
|
||||
currentTargets: readonly string[] = [],
|
||||
): string[] {
|
||||
return rows
|
||||
.filter((row) => row.name !== selfName)
|
||||
.filter((row) => row.categories.length === 0 || currentTargets.includes(row.name))
|
||||
.map((row) => row.name)
|
||||
}
|
||||
|
||||
function uniqueMirrorName(state: StorageAdminState, draft: StorageConfig, primaryName: string): string {
|
||||
const taken = new Set([...draft.backends.map((b) => b.name), ...state.backends.map((b) => b.name)])
|
||||
const base = `${primaryName}-mirror`
|
||||
if (!taken.has(base)) return base
|
||||
for (let n = 2; ; n++) {
|
||||
if (!taken.has(`${base}-${n}`)) return `${base}-${n}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* targets non-empty: update the adopted mirror in place (or synthesize one)
|
||||
* and route every category that effectively resolves to the primary —
|
||||
* default-sourced included — through it. targets empty: dissolve, re-pointing
|
||||
* the mirror's categories at the primary and dropping entries that revert to
|
||||
* their built-in default.
|
||||
*/
|
||||
export function setMirrorTargets(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
primaryName: string,
|
||||
targets: string[],
|
||||
): StorageConfig {
|
||||
const adopted = adoptedMirrorFor(draft, primaryName)
|
||||
|
||||
if (targets.length === 0) {
|
||||
if (!adopted) return draft
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] !== adopted.name) continue
|
||||
const stateEntry = state.categories[category]! // exhaustive by schema contract
|
||||
if (stateEntry.source === 'default' && stateEntry.backend === primaryName) delete categories[category]
|
||||
else categories[category] = primaryName
|
||||
}
|
||||
return { backends: draft.backends.filter((b) => b.name !== adopted.name), categories }
|
||||
}
|
||||
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const mirror: StorageBackend = {
|
||||
name: adopted?.name ?? uniqueMirrorName(state, draft, primaryName),
|
||||
type: 'mirror',
|
||||
options: { primary: primaryName, replicas: [...targets] },
|
||||
}
|
||||
const backends = adopted
|
||||
? draft.backends.map((b) => (b.name === adopted.name ? mirror : b))
|
||||
: [...draft.backends, mirror]
|
||||
const categories = { ...draft.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (effective[category] === primaryName) categories[category] = mirror.name
|
||||
}
|
||||
return { backends, categories }
|
||||
}
|
||||
|
||||
/** Rewrite every reference to a renamed backend: mirror primaries, replicas, category entries. */
|
||||
export function renameBackendRefs(draft: StorageConfig, oldName: string, newName: string): StorageConfig {
|
||||
return {
|
||||
backends: draft.backends.map((b) => {
|
||||
if (!isMirror(b)) return b
|
||||
return {
|
||||
...b,
|
||||
options: {
|
||||
primary: b.options.primary === oldName ? newName : b.options.primary,
|
||||
replicas: b.options.replicas.map((r) => (r === oldName ? newName : r)),
|
||||
},
|
||||
}
|
||||
}),
|
||||
categories: Object.fromEntries(
|
||||
Object.entries(draft.categories).map(([category, backend]) => [category, backend === oldName ? newName : backend]),
|
||||
) as StorageConfig['categories'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a backend, every mirror wrapping it as primary, and every replica
|
||||
* reference to it in the remaining mirrors — a mirror stripped of its last
|
||||
* replica dissolves (with the same category re-pointing as an explicit
|
||||
* dissolve), so the draft never carries a phantom reference the composer
|
||||
* cannot render or repair.
|
||||
*/
|
||||
export function removeBackendAndMirrors(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
name: string,
|
||||
): StorageConfig {
|
||||
const stripped = draft.backends
|
||||
.filter((b) => b.name !== name && !(isMirror(b) && b.options.primary === name))
|
||||
.map((b) =>
|
||||
isMirror(b) && b.options.replicas.includes(name)
|
||||
? { ...b, options: { ...b.options, replicas: b.options.replicas.filter((r) => r !== name) } }
|
||||
: b,
|
||||
)
|
||||
let next: StorageConfig = { ...draft, backends: stripped }
|
||||
for (const mirror of stripped.filter(isMirror)) {
|
||||
if (mirror.options.replicas.length > 0) continue
|
||||
if (adoptedMirrorFor(next, mirror.options.primary)?.name === mirror.name) {
|
||||
next = setMirrorTargets(state, next, mirror.options.primary, [])
|
||||
} else {
|
||||
// A degenerate duplicate stripped empty: drop it and re-point its categories.
|
||||
const categories = { ...next.categories }
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
if (categories[category] === mirror.name) categories[category] = mirror.options.primary
|
||||
}
|
||||
next = { backends: next.backends.filter((b) => b.name !== mirror.name), categories }
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
/** Primaries whose mirrors use the named backend as a replica — pre-check copy stays in primary names. */
|
||||
export function replicaOfPrimaries(draft: StorageConfig, name: string): string[] {
|
||||
return draft.backends
|
||||
.filter(isMirror)
|
||||
.filter((m) => m.options.replicas.includes(name))
|
||||
.map((m) => m.options.primary)
|
||||
}
|
||||
|
||||
/** Map a mirror name (draft first, then state) to its primary; anything else passes through. */
|
||||
export function primaryNameOf(state: StorageAdminState, draft: StorageConfig, backendName: string): string {
|
||||
const draftMirror = draft.backends.filter(isMirror).find((m) => m.name === backendName)
|
||||
if (draftMirror) return draftMirror.options.primary
|
||||
const stateMirror = state.backends.find((b) => b.name === backendName && b.type === 'mirror')
|
||||
if (stateMirror) return String((stateMirror.options as Record<string, unknown>).primary)
|
||||
return backendName
|
||||
}
|
||||
|
||||
/**
|
||||
* Usage sums per VISIBLE backend row: each category's numbers land on the row
|
||||
* that serves it (a mirror-routed category on the mirror's PRIMARY row), and
|
||||
* the legacy photos directory lands on uploads-local — the only backend the
|
||||
* served-legacy `photos` category ever resolves to.
|
||||
*/
|
||||
export function usageByBackend(
|
||||
state: StorageAdminState,
|
||||
draft: StorageConfig,
|
||||
): Record<string, { objects: number; bytes: number }> | null {
|
||||
if (!state.usage) return null
|
||||
const sums: Record<string, { objects: number; bytes: number }> = {}
|
||||
const add = (row: string, entry: { objects: number; bytes: number }) => {
|
||||
const current = (sums[row] ??= { objects: 0, bytes: 0 })
|
||||
current.objects += entry.objects
|
||||
current.bytes += entry.bytes
|
||||
}
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const entry = state.usage.categories[category]
|
||||
if (entry) add(primaryNameOf(state, draft, effective[category]), entry)
|
||||
}
|
||||
// The served-legacy `photos` directory always resolves to uploads-local —
|
||||
// hardcoded, not derived, because it is not one of the configurable categories.
|
||||
add(primaryNameOf(state, draft, 'uploads-local'), state.usage.legacyPhotos)
|
||||
return sums
|
||||
}
|
||||
|
||||
// ── Category migration (copy → flip → delta sweep) ────────────────────────────
|
||||
|
||||
/** One reassigned category the operator might want to move existing objects for. */
|
||||
export interface MigrationCandidate {
|
||||
category: StorageCategory
|
||||
from: string
|
||||
/** Display primary — never a hidden mirror name; used by the prompt lines and comparisons. */
|
||||
to: string
|
||||
/**
|
||||
* The RAW effective draft backend name for this category — possibly a
|
||||
* mirror's wire name when `to` is a mirrored primary. The migration POST
|
||||
* must carry this, not `to`: posting the bare primary would silently drop
|
||||
* replication for a category that was just routed onto a mirror.
|
||||
*/
|
||||
toWire: string
|
||||
/** null when usage was never scanned — the prompt still fires, with an unknown-size line. */
|
||||
objects: number | null
|
||||
bytes: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Every category whose effective (draft-over-state) PRIMARY backend differs
|
||||
* from what's currently saved — normalized through `primaryNameOf` so simply
|
||||
* wrapping (or unwrapping) a mirror around the same primary never counts as
|
||||
* a candidate; mirroring is the existing sync/backfill flow, not a category
|
||||
* migration. Zero-object categories (usage present, 0 objects) are excluded —
|
||||
* nothing to move; null usage (never scanned) keeps the category as a
|
||||
* candidate with null counts, per spec ("unknown still prompts").
|
||||
*/
|
||||
export function computeMigrationCandidates(draft: StorageConfig, state: StorageAdminState): MigrationCandidate[] {
|
||||
const effective = effectiveCategoryMap(state, draft)
|
||||
const candidates: MigrationCandidate[] = []
|
||||
for (const category of STORAGE_CATEGORIES) {
|
||||
const from = primaryNameOf(state, draft, state.categories[category]!.backend) // exhaustive by schema contract
|
||||
const toWire = effective[category]
|
||||
const to = primaryNameOf(state, draft, toWire)
|
||||
if (from === to) continue
|
||||
const usage = state.usage?.categories[category]
|
||||
candidates.push({ category, from, to, toWire, objects: usage ? usage.objects : null, bytes: usage ? usage.bytes : null })
|
||||
}
|
||||
return candidates.filter((c) => c.objects !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert exactly the named categories in `draft` back to what's currently
|
||||
* saved in `saved` — same default/settings convention as the rest of this
|
||||
* file (drop the override when the saved entry is default-sourced, write the
|
||||
* explicit backend name otherwise). Every other draft edit is untouched.
|
||||
*/
|
||||
export function stripCategories(
|
||||
draft: StorageConfig,
|
||||
saved: StorageAdminState,
|
||||
categories: StorageCategory[],
|
||||
): StorageConfig {
|
||||
const next = { ...draft.categories }
|
||||
for (const category of categories) {
|
||||
const savedEntry = saved.categories[category]! // exhaustive by schema contract
|
||||
if (savedEntry.source === 'default') delete next[category]
|
||||
else next[category] = savedEntry.backend
|
||||
}
|
||||
return { ...draft, categories: next }
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { StorageAdminState, StorageBackend, StorageConfig, StorageConfigPut, StorageTestResponse } from '@trek/shared'
|
||||
import { adminApi } from '../../../api/client'
|
||||
import { getApiErrorMessage, type ApiError } from '../../../types'
|
||||
import { settingsDocumentOf } from './storageModel'
|
||||
|
||||
export type StorageTestResults = Record<string, StorageTestResponse | 'running'>
|
||||
|
||||
/** 50ms under vitest so poll tests run on real timers (MSW + fake timers don't mix). */
|
||||
export const BACKFILL_POLL_MS = import.meta.env.MODE === 'test' ? 50 : 5000
|
||||
|
||||
/** True for a 409 response — the version this draft was built at is no longer current (audit #7). */
|
||||
function isConflictError(err: unknown): boolean {
|
||||
return typeof err === 'object' && err !== null && 'response' in err && (err as ApiError).response?.status === 409
|
||||
}
|
||||
|
||||
export interface StorageAdmin {
|
||||
state: StorageAdminState | null
|
||||
/** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */
|
||||
draft: StorageConfigPut | null
|
||||
dirty: boolean
|
||||
loading: boolean
|
||||
loadError: string | null
|
||||
/** Server 400s/409s land here VERBATIM (400) or as the distinct conflict copy (409) — long registry messages outlive a toast. */
|
||||
saveError: string | null
|
||||
/** True while `saveError` is the 409 copy — the panels render the discard-and-reload escape next to it. */
|
||||
saveConflict: boolean
|
||||
saving: boolean
|
||||
testResults: StorageTestResults
|
||||
setDraft: (next: StorageConfig) => void
|
||||
/** PUTs `overrideDraft ?? draft` — an override lets a caller strip categories from the wire body without staging them (and marking dirty) via setDraft. */
|
||||
save: (overrideDraft?: StorageConfig) => Promise<boolean>
|
||||
test: (backend: StorageBackend) => Promise<void>
|
||||
/**
|
||||
* Probe each target individually (client-expanded mirror primary +
|
||||
* replicas — see `mirrorProbeTargets`) and merge the results into one
|
||||
* `StorageTestResponse` under `key` (the row's mirror wire name). Never
|
||||
* sends the mirror stub itself — each call probes a concrete backend.
|
||||
*/
|
||||
testMirror: (key: string, targets: StorageBackend[]) => Promise<void>
|
||||
/** null on success; the verbatim server error otherwise. */
|
||||
startBackfill: (mirrorName: string) => Promise<string | null>
|
||||
cancelBackfill: (mirrorName: string) => Promise<string | null>
|
||||
startMigration: (category: string, to: string) => Promise<string | null>
|
||||
cancelMigration: (category: string) => Promise<string | null>
|
||||
refreshStats: () => Promise<string | null>
|
||||
/** Re-GETs admin state (poll-safe — see the implementation comment below). Exposed for the panel's failed-queue recovery path. */
|
||||
refreshState: () => Promise<void>
|
||||
/**
|
||||
* The 409's only exit: throw the local edits away and reload the saved
|
||||
* settings (fresh GET → fresh draft → fresh version), clearing dirty and
|
||||
* saveError. See the implementation comment for why refreshState can't do
|
||||
* this itself.
|
||||
*/
|
||||
discardDraft: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared by the desktop and phone storage panels (DefaultUserSettingsTab
|
||||
* model: self-contained, adminApi directly, deliberately no offline core —
|
||||
* hoster-level config is online-only).
|
||||
*/
|
||||
export function useStorageAdmin(genericError: string, conflictError: string): StorageAdmin {
|
||||
const [state, setState] = useState<StorageAdminState | null>(null)
|
||||
const [draft, setDraftState] = useState<StorageConfigPut | null>(null)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [saveConflict, setSaveConflict] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testResults, setTestResults] = useState<StorageTestResults>({})
|
||||
|
||||
// Read imperatively from the poll effect/refreshState so a stale closure
|
||||
// never re-derives the draft out from under an in-flight edit.
|
||||
const dirtyRef = useRef(dirty)
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty
|
||||
}, [dirty])
|
||||
|
||||
// Read imperatively at refreshState's resolve time so a poll GET that was
|
||||
// in flight when a save started never applies after the save's own state.
|
||||
const savingRef = useRef(saving)
|
||||
useEffect(() => {
|
||||
savingRef.current = saving
|
||||
}, [saving])
|
||||
|
||||
// Bumped by every deliberate state application (initial load, save
|
||||
// success). A refreshState() call captures the seq before its GET and
|
||||
// drops the response if the seq moved on — i.e. a save applied its own
|
||||
// fresher state — while that GET was in flight.
|
||||
const stateSeq = useRef(0)
|
||||
|
||||
const applyState = useCallback((next: StorageAdminState) => {
|
||||
stateSeq.current += 1
|
||||
setState(next)
|
||||
setDraftState(settingsDocumentOf(next))
|
||||
setDirty(false)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
adminApi
|
||||
.getStorage()
|
||||
.then((data) => {
|
||||
if (!cancelled) applyState(data)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) setLoadError(getApiErrorMessage(err, genericError))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
// Re-attaches the CURRENT draft's version onto whatever shape the caller
|
||||
// hands in (the storageModel edit helpers are version-blind — they operate
|
||||
// on plain StorageConfig — so this is the one place that keeps `version`
|
||||
// pinned to what the operator's form was loaded at, no matter how many
|
||||
// local edits run in between).
|
||||
const setDraft = useCallback((next: StorageConfig) => {
|
||||
setDraftState((prev) => ({ ...next, version: prev?.version ?? 0 }))
|
||||
setDirty(true)
|
||||
}, [])
|
||||
|
||||
// Poll-safe refresh: replaces `state` unconditionally, but only re-derives
|
||||
// `draft` when the operator has no unsaved edits in flight — never touches
|
||||
// `dirty`/`saveError`, so a running poll cannot clobber a dirty draft or
|
||||
// mask a pending save error.
|
||||
//
|
||||
// Race guard: a poll GET issued before a save's PUT resolves can resolve
|
||||
// AFTER the save applied its own (fresher) response, overwriting it with
|
||||
// the pre-save world. Capture the state-application sequence number before
|
||||
// the await and drop the response if it moved on, or if a save is
|
||||
// in-flight/just landed (read via a ref so it's current at resolve time).
|
||||
//
|
||||
// Declared before `save` (which calls it on a 409) — a hook's `const`s
|
||||
// must exist before another callback's dependency array references them.
|
||||
const refreshState = useCallback(async (): Promise<void> => {
|
||||
const seq = stateSeq.current
|
||||
const next = await adminApi.getStorage()
|
||||
if (stateSeq.current !== seq || savingRef.current) return
|
||||
setState(next)
|
||||
if (!dirtyRef.current) setDraftState(settingsDocumentOf(next))
|
||||
}, [])
|
||||
|
||||
const save = useCallback(
|
||||
async (overrideDraft?: StorageConfig): Promise<boolean> => {
|
||||
if (!draft) return false
|
||||
// Always the DRAFT's own version, even for an overrideDraft (a variant
|
||||
// of the same draft with categories stripped, per stripCategories) —
|
||||
// both share the same version basis: what the operator's form was
|
||||
// loaded/refreshed at, never re-read from `state` here (that would
|
||||
// defeat the whole check — see setDraft's re-attach comment above).
|
||||
const body: StorageConfigPut = { ...(overrideDraft ?? draft), version: draft.version }
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSaveConflict(false)
|
||||
try {
|
||||
// The response is the fresh effective world, never the request echo.
|
||||
applyState(await adminApi.updateStorage(body))
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
if (isConflictError(err)) {
|
||||
// Something else (another admin's save, or a category migration's
|
||||
// flip) wrote since this draft's version was captured. Refresh
|
||||
// `state` so the operator sees the fresh world, but the dirty
|
||||
// draft itself is left untouched for review (dirty is never
|
||||
// cleared here) — refreshState() already respects dirtyRef.
|
||||
setSaveError(conflictError)
|
||||
setSaveConflict(true)
|
||||
void refreshState().catch(() => {})
|
||||
} else {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
},
|
||||
[draft, applyState, genericError, conflictError, refreshState],
|
||||
)
|
||||
|
||||
/**
|
||||
* Conflict recovery (final-review fix): after a 409 the dirty draft keeps
|
||||
* the version it was built at. refreshState deliberately leaves a dirty
|
||||
* draft's version alone — bumping it there would resurrect audit #7, where
|
||||
* the background poll floats a mid-edit draft past a migration flip it
|
||||
* never carried — so nothing else can ever move it, and every retry 409s
|
||||
* again. The operator's way out is to say so explicitly: discard the local
|
||||
* edits, reload the saved settings, and start over from the fresh version.
|
||||
* A fresh GET (not the possibly-dropped post-409 refresh) guarantees that
|
||||
* version is current, and applyState clears dirty/saveError with it.
|
||||
*/
|
||||
const discardDraft = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
applyState(await adminApi.getStorage())
|
||||
} catch (err: unknown) {
|
||||
setSaveError(getApiErrorMessage(err, genericError))
|
||||
setSaveConflict(false)
|
||||
}
|
||||
}, [applyState, genericError])
|
||||
|
||||
const test = useCallback(
|
||||
async (backend: StorageBackend): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: 'running' }))
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(backend)
|
||||
setTestResults((prev) => ({ ...prev, [backend.name]: result }))
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, genericError)
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[backend.name]: { ok: false, targets: [{ name: backend.name, ok: false, error }] },
|
||||
}))
|
||||
}
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
const testMirror = useCallback(
|
||||
async (key: string, targets: StorageBackend[]): Promise<void> => {
|
||||
setTestResults((prev) => ({ ...prev, [key]: 'running' }))
|
||||
const settled = await Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const result = await adminApi.testStorageBackend(target)
|
||||
return result.targets[0] ?? { name: target.name, ok: result.ok }
|
||||
} catch (err: unknown) {
|
||||
return { name: target.name, ok: false, error: getApiErrorMessage(err, genericError) }
|
||||
}
|
||||
}),
|
||||
)
|
||||
setTestResults((prev) => ({ ...prev, [key]: { ok: settled.every((r) => r.ok), targets: settled } }))
|
||||
},
|
||||
[genericError],
|
||||
)
|
||||
|
||||
// While any backfill or category migration is running, poll GET state so
|
||||
// progress/counts advance without the operator refreshing the page.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!state?.backfills.some((b) => b.status === 'running') &&
|
||||
!state?.migrations.some((m) => m.status === 'running')
|
||||
)
|
||||
return
|
||||
const iv = setInterval(() => {
|
||||
// A transient poll failure is retried on the next tick — it must never
|
||||
// surface as a toast or interrupt the operator's in-progress edit.
|
||||
void refreshState().catch(() => {})
|
||||
}, BACKFILL_POLL_MS)
|
||||
return () => clearInterval(iv)
|
||||
}, [state, refreshState])
|
||||
|
||||
const startBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelBackfill = useCallback(
|
||||
async (mirrorName: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageBackfill(mirrorName)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const startMigration = useCallback(
|
||||
async (category: string, to: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.startStorageMigration(category, to)
|
||||
// Awaited (unlike cancelMigration/startBackfill/cancelBackfill): the
|
||||
// queue effect's in-flight lock releases as soon as this promise
|
||||
// resolves, so admin.state must already reflect the just-started
|
||||
// migration by then — otherwise the next queued candidate can
|
||||
// dequeue and POST before the server has confirmed this one.
|
||||
await refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const cancelMigration = useCallback(
|
||||
async (category: string): Promise<string | null> => {
|
||||
try {
|
||||
await adminApi.cancelStorageMigration(category)
|
||||
void refreshState().catch(() => {})
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
},
|
||||
[refreshState, genericError],
|
||||
)
|
||||
|
||||
const refreshStats = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
const usage = await adminApi.refreshStorageStats()
|
||||
setState((prev) => (prev ? { ...prev, usage } : prev))
|
||||
return null
|
||||
} catch (err: unknown) {
|
||||
return getApiErrorMessage(err, genericError)
|
||||
}
|
||||
}, [genericError])
|
||||
|
||||
return {
|
||||
state,
|
||||
draft,
|
||||
dirty,
|
||||
loading,
|
||||
loadError,
|
||||
saveError,
|
||||
saveConflict,
|
||||
saving,
|
||||
testResults,
|
||||
setDraft,
|
||||
save,
|
||||
test,
|
||||
testMirror,
|
||||
startBackfill,
|
||||
cancelBackfill,
|
||||
startMigration,
|
||||
cancelMigration,
|
||||
refreshStats,
|
||||
refreshState,
|
||||
discardDraft,
|
||||
}
|
||||
}
|
||||
@@ -1,278 +0,0 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-022
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { screen, act, waitFor } from '@testing-library/react'
|
||||
import { render, fireEvent } from '../../../tests/helpers/render'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { addListener } from '../../api/websocket'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
import BackgroundTasksWidget from './BackgroundTasksWidget'
|
||||
|
||||
const navigate = vi.fn()
|
||||
vi.mock('react-router', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router')>('react-router')
|
||||
return { ...actual, useNavigate: () => navigate }
|
||||
})
|
||||
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({
|
||||
reservationsApi: { importJobStatus: vi.fn(), importBookingAsync: vi.fn() },
|
||||
healthApi: { features: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
|
||||
|
||||
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
|
||||
id: 'j1', tripId: 't1', label: 'voucher.pdf', status: 'done', done: 0, total: 1, items: [], warnings: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
type WsHandler = (e: Record<string, unknown>) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(reservationsApi.importJobStatus).mockReturnValue(new Promise(() => {}))
|
||||
useBackgroundTasksStore.setState({ tasks: [] })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — rendering', () => {
|
||||
it('FE-W4BGT-001: renders nothing without tasks', () => {
|
||||
const { container, baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(baseElement.querySelectorAll('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-W4BGT-002: a running job shows the spinner, the parsing note and no close button', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 1, total: 3 })] })
|
||||
const { baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(screen.getByText('voucher.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText(/· 1\/3$/)).toBeInTheDocument()
|
||||
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.queryByLabelText('Close')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-003: a single-file job omits the counter', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', done: 0, total: 1 })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(screen.queryByText(/·/)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-004: a restored done job without items still reads as parsing', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'done', items: undefined })] })
|
||||
const { baseElement } = render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(baseElement.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.getByLabelText('Close')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-005: a finished job with items offers the review action', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Import' }))
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].reviewRequested).toBe(true)
|
||||
expect(navigate).toHaveBeenCalledWith('/trips/t1')
|
||||
})
|
||||
|
||||
it('FE-W4BGT-006: a failed job shows the error message', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'error', error: 'AI quota exhausted' })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(screen.getByText('AI quota exhausted')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-007: the close button drops the card', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task()] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Close'))
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — websocket', () => {
|
||||
it('FE-W4BGT-008: import:progress updates the running card', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
|
||||
|
||||
act(() => { handler({ type: 'import:progress', jobId: 'j1', tripId: 't1', done: 2, total: 4 }) })
|
||||
|
||||
expect(screen.getByText(/· 2\/4$/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-009: import:done attaches the parsed items', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
|
||||
|
||||
act(() => { handler({ type: 'import:done', jobId: 'j1', tripId: 't1', result: { items: [{ id: 1 }], warnings: [] } }) })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-010: import:error surfaces the message', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running' })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
|
||||
|
||||
act(() => { handler({ type: 'import:error', jobId: 'j1', tripId: 't1', message: 'boom' }) })
|
||||
|
||||
expect(screen.getByText('boom')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-011: unrelated events and events without a job id are ignored', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', total: 4 })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
const handler = vi.mocked(addListener).mock.calls[0][0] as WsHandler
|
||||
|
||||
act(() => {
|
||||
handler({ type: 'place:updated', jobId: 'j1' })
|
||||
handler({ type: 'import:progress', done: 3, total: 4 })
|
||||
handler({ done: 3 })
|
||||
})
|
||||
|
||||
expect(screen.getByText(/· 0\/4$/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — rehydrate', () => {
|
||||
it('FE-W4BGT-012: a restored job that the server finished gets its items back', async () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({
|
||||
status: 'done', done: 1, total: 1, result: { items: [{ id: 1 }], warnings: [] },
|
||||
} as never)
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Import' })).toBeInTheDocument()
|
||||
expect(reservationsApi.importJobStatus).toHaveBeenCalledWith('t1', 'j1')
|
||||
})
|
||||
|
||||
it('FE-W4BGT-013: a restored job the server reports as failed shows the error', async () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus).mockResolvedValue({ status: 'error', error: 'expired', done: 0, total: 1 } as never)
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(await screen.findByText('expired')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-014: a restored job the server has dropped is removed', async () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await waitFor(() => expect(useBackgroundTasksStore.getState().tasks).toHaveLength(0))
|
||||
})
|
||||
|
||||
it('FE-W4BGT-015: a non-404 failure keeps the card', async () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus).mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await waitFor(() => expect(reservationsApi.importJobStatus).toHaveBeenCalled())
|
||||
expect(useBackgroundTasksStore.getState().tasks).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — poll backstop', () => {
|
||||
it('FE-W4BGT-021: a job the server drops mid-poll ends as an error instead of spinning on', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 404 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('error')
|
||||
// The file parsed fine — blaming it would send the user looking for a problem
|
||||
// that isn't there.
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].error).toBe('Unknown error')
|
||||
})
|
||||
|
||||
it('FE-W4BGT-022: a transient poll failure leaves the job running', async () => {
|
||||
vi.useFakeTimers()
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ status: 'running', items: undefined })] })
|
||||
vi.mocked(reservationsApi.importJobStatus)
|
||||
.mockResolvedValueOnce({ status: 'running', done: 0, total: 1 } as never)
|
||||
.mockRejectedValue({ response: { status: 500 } })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
|
||||
|
||||
expect(useBackgroundTasksStore.getState().tasks[0].status).toBe('running')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget — AI retry', () => {
|
||||
const withFiles = () => task({
|
||||
items: [], sourceFiles: [new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })],
|
||||
})
|
||||
|
||||
it('FE-W4BGT-016: offers the AI retry only when the feature is on', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
|
||||
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /AI/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-017: hides the retry when the feature probe fails', async () => {
|
||||
vi.mocked(healthApi.features).mockRejectedValue(new Error('down'))
|
||||
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-018: hides the retry on a job that already ran with force-ai', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ items: [], mode: 'force-ai', sourceFiles: [new File([''], 'a.pdf')] })] })
|
||||
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('button', { name: /AI/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BGT-019: retrying swaps the card for the new force-ai job', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
|
||||
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' } as never)
|
||||
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const tasks = useBackgroundTasksStore.getState().tasks
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({ id: 'j2', mode: 'force-ai', tripId: 't1' })
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-W4BGT-020: a refused retry surfaces the server error on the original card', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ aiParsing: true } as never)
|
||||
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No model configured' } } })
|
||||
useBackgroundTasksStore.setState({ tasks: [withFiles()] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /AI/i }))
|
||||
|
||||
expect(await screen.findByText('No model configured')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
import BackgroundTasksWidget from './BackgroundTasksWidget'
|
||||
|
||||
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({
|
||||
// Keep the rehydrate/poll backstops pending so the seeded state is what renders.
|
||||
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})), importBookingAsync: vi.fn() },
|
||||
healthApi: { features: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
|
||||
|
||||
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
|
||||
id: 'j1',
|
||||
tripId: 't1',
|
||||
label: 'voucher.pdf',
|
||||
status: 'done',
|
||||
done: 0,
|
||||
total: 1,
|
||||
items: [],
|
||||
warnings: [],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const pdf = () => new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Like the poll backstop above: leave the feature probe pending so tests that don't care
|
||||
// about the AI retry render the same widget they did before the button existed.
|
||||
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(saveImportFiles).mockResolvedValue(undefined)
|
||||
useBackgroundTasksStore.setState({ tasks: [] })
|
||||
})
|
||||
|
||||
describe('BackgroundTasksWidget', () => {
|
||||
it('shows the warnings when a finished job produced no items', () => {
|
||||
const warning = 'voucher.pdf: AI parsing failed — LLM request failed (400): response_format unsupported'
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ warnings: [warning] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(screen.getByText(warning)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only the empty-preview note when there are no warnings', () => {
|
||||
useBackgroundTasksStore.setState({ tasks: [task()] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('AI retry', () => {
|
||||
it('offers the retry on an empty result once the addon reports AI parsing', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(await screen.findByRole('button', { name: 'Try AI parsing' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when the addon is off, the files are gone, or the run was already force-ai', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: false })
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
|
||||
const { unmount } = render(<BackgroundTasksWidget />)
|
||||
await waitFor(() => expect(healthApi.features).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
// Rehydrated from storage: sourceFiles can't survive a reload, so there is nothing to resend.
|
||||
useBackgroundTasksStore.setState({ tasks: [task()] })
|
||||
const withoutFiles = render(<BackgroundTasksWidget />)
|
||||
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
|
||||
withoutFiles.unmount()
|
||||
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()], mode: 'force-ai' })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
await waitFor(() => expect(healthApi.features).toHaveBeenCalledTimes(3))
|
||||
expect(screen.queryByRole('button', { name: 'Try AI parsing' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('re-submits the files with force-ai, keeps them for the review and replaces the task', async () => {
|
||||
const file = pdf()
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
vi.mocked(reservationsApi.importBookingAsync).mockResolvedValue({ jobId: 'j2' })
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [file] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
|
||||
|
||||
expect(reservationsApi.importBookingAsync).toHaveBeenCalledWith('t1', [file], 'force-ai')
|
||||
// Without this the reviewed bookings lose their source document after a reload.
|
||||
await waitFor(() => expect(saveImportFiles).toHaveBeenCalledWith('j2', [file]))
|
||||
await waitFor(() => {
|
||||
const tasks = useBackgroundTasksStore.getState().tasks
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({ id: 'j2', tripId: 't1', status: 'running', mode: 'force-ai' })
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the task and surfaces the server error when the retry is rejected', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
vi.mocked(reservationsApi.importBookingAsync).mockRejectedValue({ response: { data: { error: 'No AI model configured' } } })
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Try AI parsing' }))
|
||||
|
||||
expect(await screen.findByText('No AI model configured')).toBeInTheDocument()
|
||||
const tasks = useBackgroundTasksStore.getState().tasks
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({ id: 'j1', status: 'error' })
|
||||
expect(saveImportFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores a second click while the first retry is still in flight', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
// Never settles: the retry stays in flight for the whole test.
|
||||
vi.mocked(reservationsApi.importBookingAsync).mockReturnValue(new Promise<{ jobId: string }>(() => {}))
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
|
||||
render(<BackgroundTasksWidget />)
|
||||
|
||||
const button = await screen.findByRole('button', { name: 'Try AI parsing' })
|
||||
await userEvent.click(button)
|
||||
await waitFor(() => expect(button).toBeDisabled())
|
||||
await userEvent.click(button)
|
||||
|
||||
expect(reservationsApi.importBookingAsync).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
/*
|
||||
* Warnings used to appear only when nothing at all was found, so a partly
|
||||
* understood import dropped whatever it could not place without a word — a
|
||||
* station the geocoder could not locate, say (#1969), whose endpoint then
|
||||
* disappeared on save.
|
||||
*/
|
||||
it('shows the warnings on a job that did produce items', () => {
|
||||
const warning = 'voucher.pdf: could not locate "Curbside Pickup Counter 7" — set it manually before saving'
|
||||
useBackgroundTasksStore.setState({
|
||||
tasks: [task({ items: [{ id: 1 } as never], warnings: [warning] })],
|
||||
})
|
||||
render(<BackgroundTasksWidget />)
|
||||
expect(screen.getByText(new RegExp('Curbside Pickup Counter 7'))).toBeInTheDocument()
|
||||
// and the import button is still there — the job did find something
|
||||
expect(screen.getByRole('button', { name: /import/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,10 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
import { reservationsApi } from '../../api/client'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
@@ -24,34 +23,6 @@ export default function BackgroundTasksWidget() {
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
||||
|
||||
const [aiParsing, setAiParsing] = useState(false)
|
||||
useEffect(() => {
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
}, [])
|
||||
|
||||
// Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped.
|
||||
const [retrying, setRetrying] = useState<string | null>(null)
|
||||
const retryWithAi = async (task: BackgroundImportTask) => {
|
||||
const files = task.sourceFiles
|
||||
if (!files || files.length === 0 || retrying === task.id) return
|
||||
setRetrying(task.id)
|
||||
try {
|
||||
const { jobId } = await reservationsApi.importBookingAsync(task.tripId, files, 'force-ai')
|
||||
// Same as the modal's first submit: the review attaches each source document to the
|
||||
// booking it created, and only IndexedDB survives a reload mid-parse.
|
||||
await saveImportFiles(jobId, files)
|
||||
dismiss(task.id)
|
||||
addTask({ id: jobId, tripId: task.tripId, label: task.label, total: files.length, files, mode: 'force-ai' })
|
||||
} catch (err) {
|
||||
// 409 when the addon is enabled but this user has no model configured.
|
||||
const message = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
|
||||
setError(task.id, task.tripId, message ?? t('reservations.import.error'))
|
||||
} finally {
|
||||
setRetrying(null)
|
||||
}
|
||||
}
|
||||
|
||||
// On (re)load, reconcile tasks restored from localStorage with the server: a parse
|
||||
// that was still running when the page reloaded must keep its widget, so re-fetch each
|
||||
@@ -111,16 +82,11 @@ export default function BackgroundTasksWidget() {
|
||||
else if (s.status === 'error') setError(task.id, task.tripId, s.error ?? 'error')
|
||||
else setProgress(task.id, task.tripId, s.done, s.total)
|
||||
})
|
||||
.catch((err: { response?: { status?: number } }) => {
|
||||
// The server 404s a job once it has expired (or after a restart). Nothing is
|
||||
// ever coming, so end the card instead of spinning and re-polling forever.
|
||||
// Not the parse-failure message — the file was fine, the job is just gone.
|
||||
if (err?.response?.status === 404) setError(task.id, task.tripId, t('common.unknownError'))
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, 5000)
|
||||
return () => clearInterval(iv)
|
||||
}, [tasks, setProgress, setDone, setError, t])
|
||||
}, [tasks, setProgress, setDone, setError])
|
||||
|
||||
if (tasks.length === 0) return null
|
||||
|
||||
@@ -129,7 +95,7 @@ export default function BackgroundTasksWidget() {
|
||||
navigate(`/trips/${task.tripId}`)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
style={{ position: 'fixed', right: 16, bottom: 16, zIndex: 50000, display: 'flex', flexDirection: 'column', gap: 8, width: 380, maxWidth: 'calc(100vw - 32px)', fontFamily: 'var(--font-system)' }}
|
||||
>
|
||||
@@ -162,46 +128,15 @@ export default function BackgroundTasksWidget() {
|
||||
// Restored from a reload; items are being re-fetched (see the poll backstop).
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.parsing')}</div>
|
||||
) : task.items.length > 0 ? (
|
||||
<div>
|
||||
<button type="button"
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
{/* A partly-understood import warns too. Warnings used to be
|
||||
shown only when nothing at all was found, so anything the
|
||||
parse could not place — a station it could not locate, say
|
||||
(#1969) — was dropped without a word on exactly the imports
|
||||
that did produce something. */}
|
||||
{(task.warnings?.length ?? 0) > 0 && (
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
|
||||
{task.warnings!.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => review(task)}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>
|
||||
{t('reservations.import.previewEmpty')}
|
||||
{(task.warnings?.length ?? 0) > 0 && (
|
||||
<div style={{ color: '#b45309', marginTop: 3, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 96, overflowY: 'auto' }}>
|
||||
{task.warnings!.join('\n')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{aiParsing && task.mode !== 'force-ai' && task.sourceFiles && task.sourceFiles.length > 0 && (
|
||||
<button type="button"
|
||||
onClick={() => retryWithAi(task)}
|
||||
disabled={retrying === task.id}
|
||||
className="bg-surface-tertiary text-content"
|
||||
style={{ marginTop: 4, border: 'none', borderRadius: 8, padding: '4px 12px', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, cursor: retrying === task.id ? 'default' : 'pointer', opacity: retrying === task.id ? 0.6 : 1, fontFamily: 'inherit' }}
|
||||
>
|
||||
{t('reservations.import.tryAi')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: 'var(--text-faint)', marginTop: 1 }}>{t('reservations.import.previewEmpty')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -211,7 +146,7 @@ export default function BackgroundTasksWidget() {
|
||||
</div>
|
||||
|
||||
{task.status !== 'running' && (
|
||||
<button type="button"
|
||||
<button
|
||||
onClick={() => dismiss(task.id)}
|
||||
className="bg-transparent text-content-faint"
|
||||
style={{ flexShrink: 0, border: 'none', cursor: 'pointer', padding: 2, borderRadius: 6, display: 'flex', alignItems: 'center' }}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user