mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-07 21:16:44 +00:00
Compare commits
276 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8541a53e2 | |||
| 446bbdc38f | |||
| d7d7fac339 | |||
| e25a1276dc | |||
| 628f4e78cd | |||
| 4e090411fe | |||
| 8ab50dcbbb | |||
| 6019b3ed4e | |||
| 558f348881 | |||
| 97f6a43333 | |||
| d9778ed7c0 | |||
| 269bd9140f | |||
| ddba45ab3e | |||
| c42aea4171 | |||
| 414d967223 | |||
| 2ac6c76086 | |||
| ee13b6325b | |||
| ad7d81fd00 | |||
| aec2d8cd87 | |||
| 00447c9043 | |||
| d203d8cccd | |||
| c814653f4e | |||
| 74be789f22 | |||
| 5d4b2abd26 | |||
| e65f851446 | |||
| f40c66da6b | |||
| 5da98d0fb9 | |||
| ec0709741a | |||
| d892b61a59 | |||
| 9bc31e6a1c | |||
| fb87202683 | |||
| 45564b0a8a | |||
| 464fe5ff77 | |||
| ae3ba18b9e | |||
| c78134831f | |||
| 79c20c07c8 | |||
| d11b7bb054 | |||
| cab49ea511 | |||
| 4f8300e2ba | |||
| 2b03f9a024 | |||
| e80ae79b77 | |||
| 57cc676e8a | |||
| 1fb6d19f6f | |||
| f02f255016 | |||
| 4f0f80ad69 | |||
| 57017b8aed | |||
| cd67074e0b | |||
| 4e0ac74fcb | |||
| c7760cb823 | |||
| f4bf7cf3dc | |||
| 129f952db4 | |||
| eb817dea65 | |||
| 5dbb2db3ee | |||
| be5b48532d | |||
| 03be438e0b | |||
| dc4ccb2304 | |||
| a7cb4eb880 | |||
| 09f7cc4811 | |||
| 74e1e444c5 | |||
| 4706112516 | |||
| 2e40c19249 | |||
| 397f504d01 | |||
| edea67ff7e | |||
| e7ccbcdf86 | |||
| 98ad2450a2 | |||
| e62dceb4bb | |||
| 14bc41f2cf | |||
| f35c3f31e2 | |||
| 4f99834965 | |||
| 8b7e2ddaa5 | |||
| 724b52aa92 | |||
| aaef3e3e76 | |||
| 9ab9665e99 | |||
| 50600b74cd | |||
| 9ecca404c5 | |||
| f013c4964c | |||
| 72a82b3c44 | |||
| a4bf815d24 | |||
| 1d9fe9a3df | |||
| be0164eb91 | |||
| 16922766e5 | |||
| 99816bff4c | |||
| 02ad6c3d16 | |||
| b80bd3992b | |||
| 1e66bcc701 | |||
| bbb0f93af2 | |||
| a3b263017f | |||
| 12558fe9c4 | |||
| c071901e55 | |||
| c884ddf527 | |||
| 287dceb532 | |||
| 530c7cb005 | |||
| a3c2cfa4b2 | |||
| 23486f841f | |||
| a8a5f7e611 | |||
| a2011dc991 | |||
| f66f8d8bd9 | |||
| c0baaeb898 | |||
| a2861d40f3 | |||
| 80f3958569 | |||
| c7dc38a5b9 | |||
| 65fb00d63b | |||
| 59ea420f1d | |||
| ee2ea1d065 | |||
| cafff003a3 | |||
| 292f1b181c | |||
| 2b8b55aa8f | |||
| 502f604ca2 | |||
| 3c575a06f4 | |||
| fcabb4c645 | |||
| 1b5317a817 | |||
| 351b5fb4ec | |||
| 40f3103c04 | |||
| cf1b9e4c06 | |||
| 7f34dadf70 | |||
| b0041f34ba | |||
| 166fe798a7 | |||
| a3e6eb52a4 | |||
| 132eb2035f | |||
| 2944cd32a1 | |||
| 6ff936c613 | |||
| 1d56de3897 | |||
| 55cb2148d4 | |||
| c276e3a79a | |||
| 2d1eec76c3 | |||
| e62628bf93 | |||
| d952eaf4d4 | |||
| c7dde2af2e | |||
| 203d67c45f | |||
| c27fc99220 | |||
| fbc773386f | |||
| e3547ec43b | |||
| 588457710c | |||
| 29e89a5774 | |||
| a8d6663dc7 | |||
| 07b8f1234c | |||
| 20882f47b7 | |||
| 49197c5370 | |||
| c08c595b7e | |||
| 470e4dc92c | |||
| f69731b72b | |||
| f3e5be78c9 | |||
| dd3c03ba0f | |||
| 4a7d9cd0be | |||
| e0530680cb | |||
| cece6509b9 | |||
| c9a9df5310 | |||
| d572b1ec8d | |||
| 72c3edda1a | |||
| efd0e7fa98 | |||
| 41b51849b7 | |||
| 164f5152be | |||
| 9650ad4597 | |||
| cb0cd3300d | |||
| 3762d3306c | |||
| 4e49e9c21e | |||
| 7242812b27 | |||
| 934848e114 | |||
| 5da2abf4f9 | |||
| f1960dc364 | |||
| 67b3005d36 | |||
| 811230fa5d | |||
| 167db04d69 | |||
| 6c583c2f27 | |||
| 8e6b3a424e | |||
| 2fa075a076 | |||
| ad7d349c85 | |||
| 79946cc711 | |||
| b5ebffa115 | |||
| 2ce40724d7 | |||
| c8369bd14b | |||
| 2068133de2 | |||
| cc8e4d480e | |||
| 9d8d0b1653 | |||
| 0fc04909a8 | |||
| 16793761be | |||
| c4f4fdd794 | |||
| 6854436c24 | |||
| eae2ade740 | |||
| 68ebc700cb | |||
| 8f3ee53841 | |||
| 828c1db489 | |||
| 3c67afeade | |||
| a3e2ff9fa5 | |||
| 53e884ad55 | |||
| dd2c2d6668 | |||
| fbe200424d | |||
| def6b84938 | |||
| 718022c7ba | |||
| 7059b686ac | |||
| 45c1568e7c | |||
| c7a67ed58a | |||
| 8a46c56a96 | |||
| e162da6dbe | |||
| f539c2aab7 | |||
| 0f3a6f80b7 | |||
| 3b31f4256b | |||
| fb9abdb76c | |||
| 7f795a2199 | |||
| d13758221e | |||
| 9e87e09d03 | |||
| a34ab288f5 | |||
| e17f165e44 | |||
| 48ba9092c3 | |||
| 39d4dc2675 | |||
| bccf4284c4 | |||
| 8b6a80fce0 | |||
| 45ce365920 | |||
| 0d9032224d | |||
| b955998905 | |||
| 50c0864bce | |||
| 60f888dc71 | |||
| 3b98721a64 | |||
| 8bf8da18f6 | |||
| 65913975d7 | |||
| f1489534bb | |||
| fbd2d17fa8 | |||
| 3c28c7eaeb | |||
| 59128566d8 | |||
| b37996ec39 | |||
| f1d520bbc2 | |||
| d984e09473 | |||
| e1c5daeccb | |||
| 9d93ed95bf | |||
| 2672a7a741 | |||
| c475e5b04c | |||
| c61203e607 | |||
| 8769ed084e | |||
| 2a5c54c025 | |||
| bc06125d6e | |||
| e89dae5528 | |||
| 8d7753c2e1 | |||
| 781c693434 | |||
| 21456b983e | |||
| 9344d4508c | |||
| 9ad3507e57 | |||
| 791468e5b3 | |||
| 3dc9ac6ac6 | |||
| 68b0c6bceb | |||
| 4b73fa80a9 | |||
| 617ffae351 | |||
| 60abcf6598 | |||
| 51f8afdb60 | |||
| 2cec06161d | |||
| 0834282bfd | |||
| ffda4de77d | |||
| 1cf1a9ca42 | |||
| 406229e701 | |||
| d2d8b65827 | |||
| ba2582f9c8 | |||
| 920628d63c | |||
| 10bfad97da | |||
| c541c158df | |||
| aec541c573 | |||
| c6d96872ae | |||
| eb0dfc83c1 | |||
| e100c52676 | |||
| fce26618a1 | |||
| 7bd9e08016 | |||
| 4d3d16389e | |||
| 8855b5581d | |||
| c5360be242 | |||
| ac723744ab | |||
| 399e3573c1 | |||
| 88cb7f22dd | |||
| b8728078c4 | |||
| 3678761a2d | |||
| 31f6ec1f47 | |||
| 7528706d00 | |||
| a3cb783df7 | |||
| b6a3cced43 | |||
| 229ec6d18e | |||
| 0b4737448d | |||
| adbee5aa13 | |||
| a099465889 | |||
| 91095ef96a |
@@ -27,11 +27,17 @@ jobs:
|
||||
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: ${{ secrets.GITHUB_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Determine bump type and update version
|
||||
id: bump
|
||||
@@ -101,9 +107,9 @@ jobs:
|
||||
# Commit and tag
|
||||
git config user.name "github-actions[bot]"
|
||||
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 add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag "v$NEW_VERSION"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
build:
|
||||
@@ -208,3 +214,4 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
charts_dir: charts
|
||||
charts_url: https://chart.liketrek.com
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'nest-mcp/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
@@ -49,6 +50,41 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
nest-mcp-package:
|
||||
name: nest-mcp Package
|
||||
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 nest-mcp
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# Same lockfile quirk as server-tests: the Linux native binary is
|
||||
# omitted, and nest-mcp's vitest config uses the SWC transform too.
|
||||
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
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Typecheck
|
||||
run: cd nest-mcp && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd nest-mcp && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd nest-mcp && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -77,9 +113,20 @@ jobs:
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Build nest-mcp
|
||||
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
|
||||
# (tests alias the package source, but tsc does not).
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Smoke production require chain
|
||||
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
|
||||
# what production runs: nest-mcp's 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('@trek/nest-mcp')"
|
||||
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
@@ -97,8 +144,12 @@ jobs:
|
||||
path: server/coverage/
|
||||
retention-days: 7
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
client-quality:
|
||||
# Split out of client-tests: the suite takes ~11 minutes, and having the
|
||||
# gates in front of it meant a single lint finding threw away the whole test
|
||||
# signal for that run. Both jobs pay the install/build, which is cheap next
|
||||
# to running the two in series.
|
||||
name: Client Types & Lint
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -125,6 +176,25 @@ jobs:
|
||||
- name: Page pattern check
|
||||
run: cd client && npm run lint:pages
|
||||
|
||||
client-tests:
|
||||
name: Client Tests
|
||||
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 && npm ci --workspace client
|
||||
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Run tests
|
||||
run: cd client && npm run test:coverage
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ yarn-error.log*
|
||||
|
||||
# Coverage
|
||||
coverage
|
||||
coverage-*/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
|
||||
|
||||
+33
-12
@@ -31,9 +31,12 @@ FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY nest-mcp/ ./nest-mcp/
|
||||
RUN npm run build --workspace=nest-mcp
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
@@ -44,8 +47,14 @@ WORKDIR /app
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
# The trailing chown runs in this layer on purpose: it covers the manifests and
|
||||
# the freshly installed node_modules while they are already part of this layer's
|
||||
# changeset, so it costs nothing. Everything copied after this point carries
|
||||
# --chown=node:node for the same reason — a recursive chown in a later layer
|
||||
# would copy up every inode it touches and duplicate the whole tree in the image.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \
|
||||
libkitinerary-bin && \
|
||||
@@ -53,7 +62,8 @@ RUN apt-get update && \
|
||||
ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \
|
||||
apt-get purge -y python3 build-essential && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \
|
||||
chown -R node:node /app
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
@@ -65,32 +75,43 @@ ENV QT_QPA_PLATFORM=offscreen
|
||||
# Override with KITINERARY_EXTRACTOR_PATH if you install it elsewhere.
|
||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||
|
||||
COPY --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --chown=node:node --from=server-builder /app/server/dist ./server/dist
|
||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
||||
# only emits dist, so these must be copied explicitly or the features silently
|
||||
# degrade to empty in the image.
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
COPY --chown=node:node --from=server-builder /app/server/assets ./server/assets
|
||||
# The in-app help pages (/help) read this straight from disk at runtime, so the
|
||||
# docs always match the version running. Without it, wikiService falls back to
|
||||
# fetching the GitHub wiki, which tracks main and needs network access.
|
||||
COPY wiki ./wiki
|
||||
COPY --chown=node:node wiki ./wiki
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
COPY --chown=node:node server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --chown=node:node --from=shared-builder /app/shared/dist ./shared/dist
|
||||
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
|
||||
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
|
||||
# hook the server already boots with.
|
||||
COPY --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
|
||||
COPY --chown=node:node --from=client-builder /app/client/dist ./server/public
|
||||
COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
# journey/ and places/ must be listed here and in server/src/index.ts (#1762) —
|
||||
# a dir created lazily on first upload needs write permission on the uploads
|
||||
# mount point itself, which fails with EACCES when the bind-mounted host dir
|
||||
# isn't writable by node. Only paths this layer creates are chowned; anything
|
||||
# already in the image arrived node-owned via --chown above.
|
||||
RUN mkdir -p /app/data/logs /app/uploads/files /app/uploads/covers /app/uploads/avatars \
|
||||
/app/uploads/photos /app/uploads/journey /app/uploads/places && \
|
||||
ln -s /app/uploads /app/server/uploads && \
|
||||
ln -s /app/data /app/server/data && \
|
||||
chown -R node:node /app
|
||||
chown -R node:node /app/data /app/uploads && \
|
||||
chown -h node:node /app/server/uploads /app/server/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -1,577 +1,3 @@
|
||||
# MCP Integration
|
||||
|
||||
TREK includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that lets AI
|
||||
assistants — such as Claude Desktop, Cursor, or any MCP-compatible client — read and modify your trip data through a
|
||||
structured API.
|
||||
|
||||
> **Note:** MCP is an addon that must be enabled by your TREK administrator before it becomes available.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Option A: OAuth 2.1 (recommended)](#option-a-oauth-21-recommended)
|
||||
- [Option B: Static API Token (deprecated)](#option-b-static-api-token-deprecated)
|
||||
- [Authentication](#authentication)
|
||||
- [OAuth Scopes](#oauth-scopes)
|
||||
- [Limitations & Important Notes](#limitations--important-notes)
|
||||
- [Resources (read-only)](#resources-read-only)
|
||||
- [Tools (read-write)](#tools-read-write)
|
||||
- [Compound Tools](#compound-tools)
|
||||
- [Prompts](#prompts)
|
||||
- [Example](#example)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Enable the MCP addon (admin)
|
||||
|
||||
An administrator must first enable the MCP addon from the **Admin Panel > Addons** page. Until enabled, the `/mcp`
|
||||
endpoint returns `404` and the MCP section does not appear in user settings.
|
||||
|
||||
### 2. Connect your MCP client
|
||||
|
||||
#### Option A: OAuth 2.1 (recommended)
|
||||
|
||||
MCP clients that support OAuth 2.1 (such as Claude Desktop via `mcp-remote`) authenticate automatically. No token
|
||||
management required — just provide the server URL:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> The path to `npx` may need to be adjusted for your system (e.g. `C:\PROGRA~1\nodejs\npx.cmd` on Windows).
|
||||
|
||||
**What happens automatically:**
|
||||
1. The client fetches `/.well-known/oauth-protected-resource` (RFC 9728) to discover the authorization server and bind the `/mcp` endpoint.
|
||||
2. The client fetches `/.well-known/oauth-authorization-server` for the full AS metadata.
|
||||
3. The client registers itself via [Dynamic Client Registration (RFC 7591)](https://www.rfc-editor.org/rfc/rfc7591).
|
||||
4. Your browser opens TREK's consent screen, where you choose which scopes (permissions) to grant.
|
||||
5. The client receives a short-lived access token audience-bound to `/mcp` (RFC 8707) and a rotating refresh token — no re-authorization needed.
|
||||
|
||||
> **Requirement:** The `APP_URL` environment variable must be set to your TREK instance's public URL for OAuth
|
||||
> discovery to work correctly.
|
||||
|
||||
**For more control over scopes or to use confidential client mode**, pre-create an OAuth client in
|
||||
**Settings > Integrations > MCP > OAuth Clients** before connecting. Clients created there have a client secret
|
||||
(`trekcs_` prefix) and fixed scopes that you define up front.
|
||||
|
||||
#### Option B: Static API Token (deprecated)
|
||||
|
||||
> **Deprecated:** Static API tokens will stop working in a future version. Migrate to OAuth 2.1 above.
|
||||
|
||||
1. Go to **Settings > Integrations > MCP** and create an API token.
|
||||
2. Click **Create New Token**, give it a name, and **copy the token immediately** — it is shown only once.
|
||||
3. Add it to your `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"trek": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://your-trek-instance.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer trek_your_token_here"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Static tokens grant full access to all tools and resources (no scope restrictions). Sessions authenticated with a
|
||||
static token will receive deprecation warnings in the AI client via server instructions and tool results.
|
||||
|
||||
Each user can create up to **10 static tokens**.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
TREK's MCP server supports three authentication methods. OAuth 2.1 is the recommended path for all external clients.
|
||||
|
||||
| Method | Token prefix | Access level | TTL | Notes |
|
||||
|--------|-------------|-------------|-----|-------|
|
||||
| **OAuth 2.1** | `trekoa_` | Scoped (per-consent) | 1 hour | Recommended. Automatically refreshed via 30-day rolling refresh tokens (`trekrf_` prefix). Replay-detected rotation — replayed tokens cascade-revoke the entire chain. |
|
||||
| **Static API token** | `trek_` | Full access | No expiry | **Deprecated.** Triggers deprecation warnings in AI clients. Will be removed in a future release. |
|
||||
| **Web session JWT** | — | Full access | Session-based | Used internally by the TREK web UI. Not intended for external clients. |
|
||||
|
||||
All methods require the `Authorization: Bearer <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)
|
||||
|
||||

|
||||
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
|
||||
@@ -20,7 +20,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<a href="https://demo.liketrek.com"><img alt="Demo" src="https://img.shields.io/badge/Demo-try-111827?style=for-the-badge" /></a>
|
||||
|
||||
<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://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://discord.gg/NhZBDSd4qW"><img alt="Discord" src="https://img.shields.io/badge/Discord-join-5865F2?style=for-the-badge" /></a>
|
||||
|
||||
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<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://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/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://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>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -133,7 +133,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **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
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, approved school holiday overlays, 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
|
||||
@@ -176,7 +176,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
```bash
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32) docker run -d -p 3000:3000 \
|
||||
-e ENCRYPTION_KEY=$ENCRYPTION_KEY \
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/TREK
|
||||
-v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`. On first boot TREK seeds an admin account — if you set `ADMIN_EMAIL`/`ADMIN_PASSWORD` those are used, otherwise the credentials are printed to the container log (`docker logs trek`).
|
||||
@@ -217,7 +217,7 @@ Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. A
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: mauriceboe/TREK:latest
|
||||
image: mauriceboe/trek:latest
|
||||
container_name: trek
|
||||
read_only: true
|
||||
security_opt:
|
||||
@@ -275,7 +275,7 @@ docker compose up -d
|
||||
<h2 id="helm-kubernetes">Helm (Kubernetes)</h2>
|
||||
|
||||
```bash
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
@@ -305,9 +305,9 @@ docker compose pull && docker compose up -d
|
||||
**Docker run** — reuse the original volume paths:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/TREK
|
||||
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
|
||||
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.
|
||||
@@ -405,6 +405,13 @@ Caddy handles TLS and WebSockets automatically.
|
||||
|
||||
## Environment variables
|
||||
|
||||
> [!NOTE]
|
||||
> Variables are validated at startup (fail-fast). An unset or blank variable
|
||||
> always falls back to its default, but a variable set to a malformed value
|
||||
> (e.g. `PORT=abc`, `SESSION_DURATION=bogus`, `DEMO_MODE=maybe`) aborts boot
|
||||
> with a report listing every offending variable. Boolean switches accept
|
||||
> `true`/`false`, `1`/`0`, `on`/`off` and `yes`/`no` (any casing).
|
||||
|
||||
<details>
|
||||
<summary><b>Full reference</b></summary>
|
||||
|
||||
@@ -472,4 +479,3 @@ for full third-party attributions.
|
||||
## License
|
||||
|
||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/TREK`).
|
||||
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||
|
||||
Third-party dependencies are monitored via GitHub Dependabot.
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||
CLIENT_DIR="$REPO_ROOT/client"
|
||||
SERVER_DIR="$REPO_ROOT/server"
|
||||
PUBLIC_DIR="$REPO_ROOT/server/public"
|
||||
|
||||
echo "==> Installing client dependencies"
|
||||
cd "$CLIENT_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Building client"
|
||||
npm run build
|
||||
|
||||
echo "==> Installing server dependencies"
|
||||
cd "$SERVER_DIR"
|
||||
npm ci
|
||||
|
||||
echo "==> Populating server/public"
|
||||
find "$PUBLIC_DIR" -mindepth 1 ! -name '.gitkeep' -delete
|
||||
cp -r "$CLIENT_DIR/dist/." "$PUBLIC_DIR/"
|
||||
cp -r "$CLIENT_DIR/public/fonts" "$PUBLIC_DIR/fonts"
|
||||
|
||||
echo "==> Done — server/public is ready"
|
||||
+3
-1
@@ -15,11 +15,13 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
A hosted Helm repository is available:
|
||||
|
||||
```sh
|
||||
helm repo add trek https://mauriceboe.github.io/TREK
|
||||
helm repo add trek https://chart.liketrek.com
|
||||
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: 3.4.0
|
||||
version: 3.4.1
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.4.0"
|
||||
appVersion: "3.4.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
image:
|
||||
repository: liketrek/TREK
|
||||
repository: mauriceboe/trek
|
||||
# tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ e2e/.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# vite-plugin-pwa dev output (devOptions.enabled)
|
||||
dev-dist/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { dismissSystemNotices } from './helpers'
|
||||
|
||||
// Relative to the config dir (client/), matching `storageState` in
|
||||
// playwright.config.ts. Playwright runs from the client workspace root.
|
||||
@@ -27,16 +28,12 @@ setup('authenticate the seeded admin (incl. forced password change)', async ({ p
|
||||
|
||||
await page.waitForURL('**/dashboard', { timeout: 30_000 })
|
||||
|
||||
// Dismiss the first-run "Welcome to TREK" system-notice modal(s). It renders
|
||||
// asynchronously (after the notices fetch), so wait for it before clicking.
|
||||
// Dismissal is recorded server-side against this user, so clearing it here
|
||||
// keeps it cleared for every authenticated flow in the run (shared test DB).
|
||||
const ok = page.getByRole('button', { name: 'OK', exact: true })
|
||||
await ok.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {})
|
||||
for (let i = 0; i < 8 && (await ok.isVisible().catch(() => false)); i++) {
|
||||
await ok.click()
|
||||
await page.waitForTimeout(400)
|
||||
}
|
||||
// Dismiss the first-run system-notice modal(s) — currently the thank-you /
|
||||
// support modal, which has NO "OK" button (only CTAs + the X). The shared
|
||||
// helper handles both notice shapes; dismissal is recorded server-side
|
||||
// against this user, so clearing it here keeps it cleared for every
|
||||
// authenticated flow in the run (shared test DB).
|
||||
await dismissSystemNotices(page, 10_000)
|
||||
|
||||
await page.context().storageState({ path: stateFile })
|
||||
})
|
||||
|
||||
+35
-14
@@ -1,22 +1,43 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Dismiss the release-notice modal (SystemNoticeHost), which greets a freshly seeded
|
||||
* user on first load and covers the dashboard — its backdrop swallows clicks aimed at
|
||||
* anything underneath, `.add-trip-card` included.
|
||||
* Dismiss the system-notice modal(s) (SystemNoticeHost), which greet a freshly
|
||||
* seeded user on first load and cover the dashboard — the backdrop swallows
|
||||
* clicks aimed at anything underneath, `.add-trip-card` included.
|
||||
*
|
||||
* The X only appears on the notice's last page, so page through first. Dismissal is
|
||||
* persisted server-side per user, but each spec gets a fresh DB, so every spec that
|
||||
* touches the dashboard has to clear it.
|
||||
* The host renders asynchronously (after the notices fetch), so wait for the
|
||||
* notice dialog before deciding there is nothing to clear. Every lookup is
|
||||
* scoped INSIDE the dialog — an unscoped /next/i can match dashboard buttons
|
||||
* (carousel arrows) and satisfy the wait before the modal even mounts.
|
||||
*
|
||||
* A notice closes one of two ways depending on its shape:
|
||||
* - CTA-bearing notices (e.g. the thank-you/support modal) only offer the
|
||||
* X button (`aria-label="Dismiss"`), shown on the last page.
|
||||
* - CTA-less notices show an "OK" button that pages forward and dismisses on
|
||||
* the last page.
|
||||
* Multi-page notices are paged through via the pager's Next button first.
|
||||
* Dismissal is persisted server-side per user, so clearing once keeps it
|
||||
* cleared for every later spec in the run (shared test DB).
|
||||
*/
|
||||
export async function dismissSystemNotices(page: Page): Promise<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())) break
|
||||
await next.click()
|
||||
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)
|
||||
}
|
||||
|
||||
const dismiss = page.getByRole('button', { name: 'Dismiss' })
|
||||
if (await dismiss.isVisible().catch(() => false)) await dismiss.click()
|
||||
await dismiss.waitFor({ state: 'detached' }).catch(() => {})
|
||||
await dialog.waitFor({ state: 'detached', timeout: 5_000 }).catch(() => {})
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -86,7 +86,7 @@
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"sharp": "^0.33.0",
|
||||
"sharp": "^0.35.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
|
||||
+11
-8
@@ -27,8 +27,10 @@ import InAppNotificationsPage from './pages/InAppNotificationsPage.tsx'
|
||||
import OAuthAuthorizePage from './pages/OAuthAuthorizePage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import SaveToCollectionModal from './components/Collections/SaveToCollectionModal'
|
||||
import MSaveToCollectionSheet from './components/Collections/MSaveToCollectionSheet'
|
||||
import BackgroundTasksWidget from './components/BackgroundTasks/BackgroundTasksWidget'
|
||||
import BottomNav from './components/Layout/BottomNav'
|
||||
import MobileShell from './mobile/MobileShell'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
@@ -53,6 +55,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
const addonStore = useAddonStore()
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -87,12 +90,11 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
// 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.
|
||||
return <MobileShell isPhone={isPhone}>{children}</MobileShell>
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
@@ -200,6 +202,7 @@ export default function App() {
|
||||
}
|
||||
}, [settings.dark_mode, settings.appearance, isSharedPage])
|
||||
|
||||
const isPhone = useIsPhone()
|
||||
const isAuthPage = location.pathname.startsWith('/login')
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
@@ -210,7 +213,7 @@ export default function App() {
|
||||
{!isAuthPage && <SystemNoticeHost />}
|
||||
<ToastContainer />
|
||||
{!isAuthPage && <BackgroundTasksWidget />}
|
||||
{!isAuthPage && <SaveToCollectionModal />}
|
||||
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<OfflineBanner />
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
// 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 a random token 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) })
|
||||
|
||||
const key = String(sink[0].headers['X-Idempotency-Key'])
|
||||
expect(key).toMatch(/^[a-z0-9]+$/)
|
||||
expect(key).not.toMatch(/-/)
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,843 @@
|
||||
// FE-APISURF-001 to FE-APISURF-052
|
||||
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,
|
||||
} 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: '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-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')
|
||||
})
|
||||
})
|
||||
+106
-16
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
inAppListResultSchema, type InAppListResult,
|
||||
@@ -26,7 +27,7 @@ import {
|
||||
type BudgetCreateItemRequest, type BudgetUpdateItemRequest,
|
||||
type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest,
|
||||
type TodoCreateItemRequest, type TodoUpdateItemRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest,
|
||||
type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest,
|
||||
type PlaceBulkDeleteRequest,
|
||||
type PlaceBulkUpdateRequest,
|
||||
type DayNoteCreateRequest, type DayNoteUpdateRequest,
|
||||
@@ -104,6 +105,9 @@ 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 {
|
||||
@@ -227,9 +231,11 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
const data = error.response.data as { error?: string } | undefined
|
||||
if (data && typeof data === 'object') {
|
||||
data.error = translated
|
||||
const data = error.response.data
|
||||
// Only a plain object body carries an `error` field worth overwriting;
|
||||
// an array (a validation-error list) or a string is replaced outright.
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
(data as { error?: string }).error = translated
|
||||
} else {
|
||||
error.response.data = { error: translated }
|
||||
}
|
||||
@@ -387,6 +393,8 @@ export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
// Whole-day default route mode (#1281); per-segment leg modes override it.
|
||||
updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
@@ -398,6 +406,15 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
uploadImage: (tripId: number | string, id: number | string, file: File) => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', file)
|
||||
return postMultipart<{ place: Place }>(`/trips/${tripId}/places/${id}/image`, fd)
|
||||
},
|
||||
rate: (tripId: number | string, id: number | string, rating: number | null): Promise<{ place: Place }> =>
|
||||
rating === null
|
||||
? apiClient.delete(`/trips/${tripId}/places/${id}/rating`).then(r => r.data)
|
||||
: apiClient.put(`/trips/${tripId}/places/${id}/rating`, { rating }).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
@@ -433,6 +450,8 @@ 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).
|
||||
updateTransport: (tripId: number | string, id: number, mode: string | null) => apiClient.put(`/trips/${tripId}/assignments/${id}/transport`, { transport_mode: mode } satisfies AssignmentTransportRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
@@ -537,24 +556,35 @@ export const adminApi = {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ baseUrl, model }),
|
||||
})
|
||||
if (!res.ok || !res.body) {
|
||||
if (!res.ok) {
|
||||
let msg = `Pull failed (${res.status})`
|
||||
try { msg = (await res.json())?.error ?? msg } catch { /* non-json */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
// An accepted request without a stream can't be followed to completion.
|
||||
if (!res.body) throw new Error('Pull returned no progress stream')
|
||||
const reader = res.body.getReader()
|
||||
const dec = new TextDecoder()
|
||||
let buf = ''
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
try { onProgress(JSON.parse(line)) } catch { /* skip partial */ }
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += dec.decode(value, { stream: true })
|
||||
// split() always yields at least one element; the last one is the
|
||||
// trailing (possibly partial) line carried into the next chunk.
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop()!
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
// Only the parse is swallowed — a throw from onProgress aborts the pull.
|
||||
let frame: { status?: string; total?: number; completed?: number; error?: string }
|
||||
try { frame = JSON.parse(line) } catch { continue }
|
||||
onProgress(frame)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.cancel().catch(() => {})
|
||||
}
|
||||
},
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
@@ -624,6 +654,50 @@ 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';
|
||||
}
|
||||
|
||||
/** A route computed by a routeProvider plugin (server-normalized: coordinates
|
||||
* range-checked, legs forced to waypoints-1, vias capped). null = provider failed
|
||||
* or refused — the caller falls back to straight lines like on an OSRM outage. */
|
||||
export interface PluginRouteResult {
|
||||
pluginId: string; profile: string;
|
||||
coordinates: Array<[number, number]>;
|
||||
distance: number; duration: number;
|
||||
legs: Array<{ distance: number; duration: number; note?: string }>;
|
||||
viaPoints: Array<{ lat: number; lng: number; label?: string; tone: 'default' | 'success' | 'warn' | 'danger'; dwellSeconds?: number }>;
|
||||
}
|
||||
|
||||
/** A text-only section a pdfSectionProvider plugin appends to the trip PDF export.
|
||||
* Server-normalized: counts + lengths are capped, cells are plain strings. */
|
||||
export interface PluginPdfSection {
|
||||
@@ -667,6 +741,19 @@ 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[] }),
|
||||
// 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) =>
|
||||
@@ -814,8 +901,8 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
@@ -860,6 +947,8 @@ export const reservationsApi = {
|
||||
create: (tripId: number | string, data: ReservationCreateRequest) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: ReservationUpdateRequest) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
// Assign trip members / named guests to a booking (#1517).
|
||||
setTravelers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/reservations/${id}/travelers`, { user_ids: userIds }).then(r => r.data),
|
||||
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[], dayId?: number) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions, day_id: dayId }).then(r => r.data),
|
||||
importBookingPreview: (tripId: number | string, files: File[], mode: BookingImportMode = 'no-ai'): Promise<BookingImportPreviewResponse> => {
|
||||
const fd = new FormData()
|
||||
@@ -890,6 +979,7 @@ export const healthApi = {
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string): Promise<WeatherResult> => apiClient.get('/weather', { params: { lat, lng, date } }).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')),
|
||||
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')),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032
|
||||
//
|
||||
// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a
|
||||
// request-body shape that the server contract depends on. These tests drive each method
|
||||
// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`.
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { http, HttpResponse, type JsonBodyType } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { collectionsApi } from './collections'
|
||||
import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared'
|
||||
|
||||
const BASE = '/api/addons/collections'
|
||||
|
||||
const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true }
|
||||
const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' }
|
||||
const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' }
|
||||
|
||||
let requestUrl = ''
|
||||
let requestBody: unknown
|
||||
|
||||
beforeEach(() => {
|
||||
requestUrl = ''
|
||||
requestBody = undefined
|
||||
})
|
||||
|
||||
/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */
|
||||
function record<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)
|
||||
})
|
||||
})
|
||||
@@ -70,8 +70,14 @@ export const collectionsApi = {
|
||||
ax.post(`${base}/places/from-trip-many`, { collection_id: collectionId, source_trip_id: tripId, source_place_ids: placeIds, force }).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),
|
||||
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> =>
|
||||
|
||||
@@ -7,6 +7,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
const expected = [
|
||||
'trips:read', 'trips:write', 'trips:delete', 'trips:share',
|
||||
'places:read', 'places:write',
|
||||
'collections:read', 'collections:write',
|
||||
'atlas:read', 'atlas:write',
|
||||
'packing:read', 'packing:write',
|
||||
'todos:read', 'todos:write',
|
||||
@@ -16,6 +17,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
'notifications:read', 'notifications:write',
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -32,8 +34,8 @@ describe('SCOPE_GROUPS', () => {
|
||||
})
|
||||
|
||||
describe('ALL_SCOPES', () => {
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 27 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(27)
|
||||
it('FE-OAUTH-SCOPES-003: contains exactly 29 scopes', () => {
|
||||
expect(ALL_SCOPES).toHaveLength(29)
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-004: matches Object.keys(SCOPE_GROUPS)', () => {
|
||||
|
||||
@@ -20,6 +20,8 @@ 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' },
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally)
|
||||
vi.unmock('./websocket')
|
||||
|
||||
// FE-WSCORE-001 to FE-WSCORE-014
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import {
|
||||
connect, disconnect, joinTrip, leaveTrip, getActiveTrips,
|
||||
setRefetchCallback, setPreReconnectHook,
|
||||
} from './websocket'
|
||||
|
||||
class MockWebSocket {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSING = 2
|
||||
static CLOSED = 3
|
||||
static instances: MockWebSocket[] = []
|
||||
|
||||
readyState: number = MockWebSocket.OPEN
|
||||
send = vi.fn((_data: string) => {})
|
||||
close = vi.fn(() => {})
|
||||
onopen: (() => void) | null = null
|
||||
onmessage: ((event: { data: string }) => void) | null = null
|
||||
onclose: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
|
||||
constructor(public url: string) {
|
||||
MockWebSocket.instances.push(this)
|
||||
}
|
||||
}
|
||||
|
||||
function lastSocket(): MockWebSocket {
|
||||
return MockWebSocket.instances[MockWebSocket.instances.length - 1]
|
||||
}
|
||||
|
||||
const realLocation = window.location
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
MockWebSocket.instances = []
|
||||
Object.defineProperty(globalThis, 'WebSocket', {
|
||||
writable: true, configurable: true, value: MockWebSocket,
|
||||
})
|
||||
server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
disconnect()
|
||||
setRefetchCallback(null)
|
||||
setPreReconnectHook(null)
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation })
|
||||
})
|
||||
|
||||
/** connect() + settle the token fetch so a socket exists. */
|
||||
async function openSocket(): Promise<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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
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',
|
||||
// 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-011
|
||||
// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { delay, http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
@@ -21,6 +21,45 @@ function buildAddon(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function addonsRoute(addons: ReturnType<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,
|
||||
@@ -229,4 +268,332 @@ 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-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);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import {
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard,
|
||||
Radio, Luggage, Globe, Image, CalendarDays, Bell,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText,
|
||||
Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed,
|
||||
} from 'lucide-react'
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
@@ -96,15 +96,19 @@ interface RegistryDetail extends RegistryItem {
|
||||
size: number | null
|
||||
publishedAt: string | null
|
||||
manifest: {
|
||||
permissions: string[]
|
||||
egress: string[]
|
||||
// Optional because a registry may omit an empty list — the detail view has to
|
||||
// degrade rather than throw halfway through its render.
|
||||
permissions?: string[]
|
||||
egress?: string[]
|
||||
/** The plugin needs OPERATOR-supplied hosts — its egress list is not the whole story. */
|
||||
operatorEgress?: boolean
|
||||
settings: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
settings?: Array<{ key: string; label: string; inputType: string; scope: string; required: boolean }>
|
||||
license: string | null
|
||||
icon: string | null
|
||||
requiredAddons?: string[]
|
||||
pluginDependencies?: PluginDep[]
|
||||
/** Display slice of the manifest's capabilities — drives the same chips as an installed row. */
|
||||
capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } }
|
||||
} | null
|
||||
}
|
||||
|
||||
@@ -188,6 +192,7 @@ const PERM_KEYS = [
|
||||
'events:subscribe', 'jobs:run',
|
||||
'ws:broadcast:trip', 'ws:broadcast:user',
|
||||
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'hook:table-contributor', 'hook:map-marker-provider',
|
||||
'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'geolocation:read',
|
||||
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
|
||||
]
|
||||
|
||||
@@ -239,6 +244,10 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
if (perms.includes('hook:day-schedule-provider')) out.push({ icon: Clock, label: t('admin.plugins.cap.daySchedule') })
|
||||
if (perms.includes('geolocation:read')) out.push({ icon: LocateFixed, label: t('admin.plugins.cap.geolocation') })
|
||||
if (perms.includes('hook:notification-channel')) out.push({ icon: Bell, label: t('admin.plugins.cap.notificationChannel') })
|
||||
if (perms.includes('events:subscribe')) out.push({ icon: Radio, label: t('admin.plugins.cap.events') })
|
||||
for (const h of perms.filter(p => p.startsWith('http:outbound:')).map(p => p.slice('http:outbound:'.length)).filter(Boolean)) {
|
||||
@@ -295,13 +304,8 @@ function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: s
|
||||
return { blocked: true, label: t('admin.plugins.incompatible'), title }
|
||||
}
|
||||
|
||||
function ReviewedBadge({ t, compact }: { t: T; compact?: boolean }) {
|
||||
if (compact) return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-success-soft text-success">
|
||||
<ShieldCheck size={11} /> {t('admin.plugins.reviewed')}
|
||||
</span>
|
||||
)
|
||||
function ReviewedBadge({ t }: { t: T }) {
|
||||
return <ShieldCheck size={13} className="text-success shrink-0" aria-label={t('admin.plugins.reviewed')} />
|
||||
}
|
||||
|
||||
/** Marks a manually-uploaded (sideloaded) plugin: no registry, unsigned, not reviewed. */
|
||||
@@ -1144,7 +1148,7 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, blocked
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[14.5px] font-semibold tracking-[-.006em] text-content">{p.name}</span>
|
||||
{p.version && <span className="text-[11.5px] text-content-faint font-medium tabular-nums">v{p.version}</span>}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} compact />}
|
||||
{p.reviewed_at && <ReviewedBadge t={t} />}
|
||||
{p.source_repo === 'local:upload' && <SideloadedBadge t={t} />}
|
||||
{p.source_repo === 'local:link' && <DevLinkBadge t={t} />}
|
||||
{/* Registry plugins only — a sideloaded/dev-linked plugin already says something
|
||||
@@ -1396,7 +1400,10 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
}, [item.id])
|
||||
|
||||
const manifest = detail?.manifest ?? null
|
||||
const caps = manifest ? deriveCaps(manifest.permissions, {}, t) : []
|
||||
const permissions = manifest?.permissions ?? []
|
||||
const egress = manifest?.egress ?? []
|
||||
const settings = manifest?.settings ?? []
|
||||
const caps = manifest ? deriveCaps(permissions, manifest.capabilities ?? {}, t) : []
|
||||
const repoUrl = `https://github.com/${item.repo}`
|
||||
const homepage = item.homepage && /^https?:\/\//i.test(item.homepage) && item.homepage !== repoUrl ? item.homepage : null
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
@@ -1420,7 +1427,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<div className="flex-1 min-w-0 pt-8">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-lg font-semibold tracking-tight text-content">{item.name}</h3>
|
||||
{item.reviewedAt && <ReviewedBadge t={t} compact />}
|
||||
{item.reviewedAt && <ReviewedBadge t={t} />}
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
@@ -1446,7 +1453,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
{manifest && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.accessTitle')}</h4>
|
||||
{caps.filter(c => !c.net).length === 0 && !manifest.permissions.includes('db:own') ? (
|
||||
{caps.filter(c => !c.net).length === 0 && !permissions.includes('db:own') ? (
|
||||
<p className="text-xs text-content-faint mt-2">{t('admin.plugins.noAccess')}</p>
|
||||
) : (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
@@ -1455,7 +1462,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
<c.icon size={15} className="text-accent mt-0.5 shrink-0" /><span>{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
{manifest.permissions.includes('db:own') && (
|
||||
{permissions.includes('db:own') && (
|
||||
<div className="flex items-start gap-2.5 text-[13px] text-content-secondary py-0.5">
|
||||
<Database size={15} className="text-accent mt-0.5 shrink-0" /><span>{t('admin.plugins.perm.db:own')}</span>
|
||||
</div>
|
||||
@@ -1465,11 +1472,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && (manifest.egress.length > 0 || manifest.operatorEgress) && (
|
||||
{manifest && (egress.length > 0 || manifest.operatorEgress) && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.connectsTitle')}</h4>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2">
|
||||
{manifest.egress.map(h => (
|
||||
{egress.map(h => (
|
||||
<code key={h} className="text-[12px] font-mono text-info bg-info-soft rounded-md px-2 py-1">{h}</code>
|
||||
))}
|
||||
{/* The hosts above are NOT the whole story for this plugin: it talks to a
|
||||
@@ -1488,11 +1495,11 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manifest && manifest.settings.length > 0 && (
|
||||
{manifest && settings.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-wider text-content-muted">{t('admin.plugins.setupTitle')}</h4>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{manifest.settings.map(s => (
|
||||
{settings.map(s => (
|
||||
<li key={s.key} className="flex items-center gap-2 text-xs text-content-muted flex-wrap">
|
||||
<span className="font-medium">{s.label}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-surface-tertiary text-content-faint">{t(`admin.plugins.scope.${s.scope}` as never)}</span>
|
||||
|
||||
@@ -310,4 +310,292 @@ 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)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-012
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-COMP-CAT-001 to FE-COMP-CAT-020
|
||||
import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -156,4 +156,148 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
// FE-ADMIN-DUS-001 to FE-ADMIN-DUS-025
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
} from '../Map/glProviders'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
|
||||
{ name: 'OpenStreetMap', url: 'https://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' },
|
||||
@@ -48,8 +49,8 @@ function normalizeProvider(value: unknown): MapProvider {
|
||||
return value === 'mapbox-gl' || value === 'maplibre-gl' ? value : 'leaflet'
|
||||
}
|
||||
|
||||
function styleForProvider(provider: MapProvider, style?: string | null): string {
|
||||
if (provider === 'leaflet') return style || MAPBOX_DEFAULT_STYLE
|
||||
/** Only the GL providers keep a style — Leaflet is handled by its callers. */
|
||||
function styleForProvider(provider: GlMapProvider, style?: string | null): string {
|
||||
if (provider === 'mapbox-gl' && isOpenFreeMapStyle(style)) return MAPBOX_DEFAULT_STYLE
|
||||
return normalizeStyleForProvider(provider, style)
|
||||
}
|
||||
@@ -114,7 +115,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
const provider = normalizeProvider(data.map_provider)
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
@@ -328,7 +329,7 @@ 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://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
placeholder="https://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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-010
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-DEVNOTIF-001 to FE-ADMIN-DEVNOTIF-016
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -110,7 +110,20 @@ describe('DevNotificationsPanel', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008: error toast shown on API failure', async () => {
|
||||
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')!);
|
||||
await screen.findByText('No channel configured');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-008b: a failure without an error field falls back to the generic text', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/dev/test-notification', () =>
|
||||
HttpResponse.json({ message: 'Server error' }, { status: 500 }),
|
||||
@@ -120,7 +133,7 @@ describe('DevNotificationsPanel', () => {
|
||||
render(<><ToastContainer /><DevNotificationsPanel /></>);
|
||||
await screen.findByText('Type Testing');
|
||||
await user.click(screen.getByText('Simple → Me').closest('button')!);
|
||||
await screen.findByText(/failed|error/i);
|
||||
await screen.findByText('Failed');
|
||||
});
|
||||
|
||||
it('FE-ADMIN-DEVNOTIF-009: changing trip selector updates payload targetId', async () => {
|
||||
@@ -157,4 +170,141 @@ 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,5 +1,6 @@
|
||||
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 {
|
||||
@@ -46,8 +47,8 @@ export default function DevNotificationsPanel(): React.ReactElement {
|
||||
try {
|
||||
await adminApi.sendTestNotification(payload)
|
||||
toast.success(`Sent: ${label}`)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, 'Failed'))
|
||||
} finally {
|
||||
setSending(null)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-032
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -18,6 +18,23 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Template rows carry [chevron, 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(
|
||||
@@ -508,4 +525,296 @@ 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')[2]);
|
||||
expect(await screen.findByText('Failed to delete template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clothing')).toBeInTheDocument();
|
||||
|
||||
await user.click(templateButtons('Beach Trip')[2]);
|
||||
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')[1]);
|
||||
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')[1]);
|
||||
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,12 +115,13 @@ 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.deleteError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteCategoryError')) }
|
||||
}
|
||||
|
||||
// Item CRUD
|
||||
const handleAddItem = async (catId: number) => {
|
||||
if (!newItemName.trim() || !expandedId) return
|
||||
// The name is already guaranteed non-empty by the button and the Enter handler.
|
||||
if (!expandedId) return
|
||||
try {
|
||||
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||
setItems(prev => [...prev, data.item])
|
||||
@@ -143,7 +144,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.deleteError')) }
|
||||
} catch { toast.error(t('admin.packingTemplates.deleteItemError')) }
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
// FE-W4BGT-001 to FE-W4BGT-020
|
||||
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-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom')
|
||||
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 — 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,14 +1,19 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
import BackgroundTasksWidget from './BackgroundTasksWidget'
|
||||
|
||||
vi.mock('../../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({
|
||||
// Keep the rehydrate/poll backstops pending so the seeded state is what renders.
|
||||
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})) },
|
||||
reservationsApi: { importJobStatus: vi.fn(() => new Promise(() => {})), importBookingAsync: vi.fn() },
|
||||
healthApi: { features: vi.fn() },
|
||||
}))
|
||||
vi.mock('../../db/offlineDb', () => ({ saveImportFiles: vi.fn(() => Promise.resolve()) }))
|
||||
|
||||
const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTask => ({
|
||||
id: 'j1',
|
||||
@@ -22,8 +27,14 @@ const task = (overrides: Partial<BackgroundImportTask> = {}): BackgroundImportTa
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const pdf = () => new File(['%PDF'], 'voucher.pdf', { type: 'application/pdf' })
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// Like the poll backstop above: leave the feature probe pending so tests that don't care
|
||||
// about the AI retry render the same widget they did before the button existed.
|
||||
vi.mocked(healthApi.features).mockReturnValue(new Promise(() => {}))
|
||||
vi.mocked(saveImportFiles).mockResolvedValue(undefined)
|
||||
useBackgroundTasksStore.setState({ tasks: [] })
|
||||
})
|
||||
|
||||
@@ -42,4 +53,84 @@ describe('BackgroundTasksWidget', () => {
|
||||
expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/AI parsing failed/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('AI retry', () => {
|
||||
it('offers the retry on an empty result once the addon reports AI parsing', async () => {
|
||||
vi.mocked(healthApi.features).mockResolvedValue({ bookingImport: true, aiParsing: true })
|
||||
useBackgroundTasksStore.setState({ tasks: [task({ sourceFiles: [pdf()] })] })
|
||||
render(<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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef, useState } 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 } from '../../api/client'
|
||||
import { reservationsApi, healthApi } from '../../api/client'
|
||||
import { saveImportFiles } from '../../db/offlineDb'
|
||||
import { useBackgroundTasksStore, type BackgroundImportTask } from '../../store/backgroundTasksStore'
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,34 @@ export default function BackgroundTasksWidget() {
|
||||
const setError = useBackgroundTasksStore((s) => s.setError)
|
||||
const requestReview = useBackgroundTasksStore((s) => s.requestReview)
|
||||
const dismiss = useBackgroundTasksStore((s) => s.dismiss)
|
||||
const addTask = useBackgroundTasksStore((s) => s.addTask)
|
||||
|
||||
const [aiParsing, setAiParsing] = useState(false)
|
||||
useEffect(() => {
|
||||
healthApi.features().then((f) => setAiParsing(!!f.aiParsing)).catch(() => setAiParsing(false))
|
||||
}, [])
|
||||
|
||||
// Re-runs the same files with force-ai: the LLM sees every file, kitinerary is skipped.
|
||||
const [retrying, setRetrying] = useState<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
|
||||
@@ -136,12 +165,24 @@ export default function BackgroundTasksWidget() {
|
||||
{t('common.import')}
|
||||
</button>
|
||||
) : (
|
||||
<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>
|
||||
<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
|
||||
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>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { calcPP, hasCustomMemberSplit } from './BudgetPanel.helpers'
|
||||
import { calcPP, hasCustomMemberSplit, normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
describe('BudgetPanel.helpers', () => {
|
||||
describe('hasCustomMemberSplit (#1458)', () => {
|
||||
@@ -25,4 +25,18 @@ describe('BudgetPanel.helpers', () => {
|
||||
expect(calcPP(100, 0)).toBeNull()
|
||||
expect(calcPP(100, null)).toBeNull()
|
||||
})
|
||||
|
||||
describe('normalizePastedAmount', () => {
|
||||
it('keeps the last separator as the decimal point', () => {
|
||||
expect(normalizePastedAmount('1.234,56 €')).toBe('1234.56')
|
||||
expect(normalizePastedAmount('$1,234.56')).toBe('1234.56')
|
||||
expect(normalizePastedAmount(' -12,5 ')).toBe('-12.5')
|
||||
})
|
||||
|
||||
it('drops everything that is not part of the number', () => {
|
||||
expect(normalizePastedAmount('EUR 1 234 567')).toBe('1234567')
|
||||
expect(normalizePastedAmount('42')).toBe('42')
|
||||
expect(normalizePastedAmount('abc')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -77,3 +77,15 @@ export function splitColorFor(userId: number, order: number) {
|
||||
export function colorForUserId(userId: number) {
|
||||
return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a pasted amount to a plain `1234.56` string: drops currency
|
||||
* symbols and spaces, treats the last comma/dot as the decimal separator and
|
||||
* removes every thousand separator before it.
|
||||
*/
|
||||
export function normalizePastedAmount(raw: string): string {
|
||||
const text = raw.trim().replace(/[^\d.,-]/g, '')
|
||||
const decimalPos = Math.max(text.lastIndexOf(','), text.lastIndexOf('.'))
|
||||
if (decimalPos === -1) return text.replace(/[.,]/g, '')
|
||||
return text.substring(0, decimalPos).replace(/[.,]/g, '') + '.' + text.substring(decimalPos + 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// FE-W4AIR-001 to FE-W4AIR-009
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import AddItemRow from './BudgetPanelAddItemRow'
|
||||
|
||||
const t = (key: string) => key
|
||||
|
||||
function setup() {
|
||||
const onAdd = vi.fn()
|
||||
const utils = render(<table><tbody><AddItemRow onAdd={onAdd} t={t} /></tbody></table>)
|
||||
return { onAdd, ...utils }
|
||||
}
|
||||
|
||||
const nameInput = () => screen.getByPlaceholderText('budget.newEntry')
|
||||
const priceInput = () => screen.getByPlaceholderText('0,00')
|
||||
const noteInput = () => screen.getByPlaceholderText('budget.table.note')
|
||||
const numberInputs = () => screen.getAllByPlaceholderText('-')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('BudgetPanelAddItemRow', () => {
|
||||
it('FE-W4AIR-001: the add button stays disabled until a name is typed', () => {
|
||||
setup()
|
||||
const button = screen.getByRole('button', { name: 'reservations.add' })
|
||||
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
|
||||
expect(button).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-W4AIR-002: submits the trimmed name with parsed numbers', () => {
|
||||
const { onAdd } = setup()
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: ' Ferry ' } })
|
||||
fireEvent.change(priceInput(), { target: { value: '129,90' } })
|
||||
fireEvent.change(numberInputs()[0], { target: { value: '2' } })
|
||||
fireEvent.change(numberInputs()[1], { target: { value: '3' } })
|
||||
fireEvent.change(noteInput(), { target: { value: ' one way ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
name: 'Ferry', total_price: 129.9, persons: 2, days: 3, note: 'one way', expense_date: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-W4AIR-003: falls back to zero price and null optionals', () => {
|
||||
const { onAdd } = setup()
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
name: 'Ferry', total_price: 0, persons: null, days: null, note: null, expense_date: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-W4AIR-004: ignores a whitespace-only name', () => {
|
||||
const { onAdd } = setup()
|
||||
|
||||
fireEvent.change(nameInput(), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(nameInput(), { key: 'Enter' })
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4AIR-005: Enter in any field submits the row', () => {
|
||||
const { onAdd } = setup()
|
||||
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
|
||||
|
||||
fireEvent.keyDown(priceInput(), { key: 'Enter' })
|
||||
expect(onAdd).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-W4AIR-006: a non-Enter key does not submit', () => {
|
||||
const { onAdd } = setup()
|
||||
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
|
||||
|
||||
fireEvent.keyDown(nameInput(), { key: 'a' })
|
||||
|
||||
expect(onAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4AIR-007: clears the row and refocuses the name field after adding', () => {
|
||||
setup()
|
||||
fireEvent.change(nameInput(), { target: { value: 'Ferry' } })
|
||||
fireEvent.change(priceInput(), { target: { value: '12' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.add' }))
|
||||
|
||||
expect(nameInput()).toHaveValue('')
|
||||
expect(priceInput()).toHaveValue('')
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(nameInput()).toHaveFocus()
|
||||
})
|
||||
|
||||
it('FE-W4AIR-008: pasting a formatted amount normalizes the separators', () => {
|
||||
setup()
|
||||
|
||||
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '1.234,56 EUR' } })
|
||||
expect(priceInput()).toHaveValue('1234.56')
|
||||
|
||||
fireEvent.paste(priceInput(), { clipboardData: { getData: () => '$2,345.67' } })
|
||||
expect(priceInput()).toHaveValue('2345.67')
|
||||
})
|
||||
|
||||
it('FE-W4AIR-009: pasting a separator-free amount keeps the digits', () => {
|
||||
setup()
|
||||
|
||||
fireEvent.paste(priceInput(), { clipboardData: { getData: () => 'EUR 4200' } })
|
||||
|
||||
expect(priceInput()).toHaveValue('4200')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
|
||||
@@ -33,7 +34,7 @@ export default function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
</td>
|
||||
<td style={{ padding: '4px 6px' }}>
|
||||
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
|
||||
onPaste={e => { e.preventDefault(); setPrice(normalizePastedAmount(e.clipboardData.getData('text'))) }}
|
||||
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
|
||||
|
||||
@@ -0,0 +1,610 @@
|
||||
// FE-W4BCT-001 to FE-W4BCT-053
|
||||
import type { CSSProperties } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { render, screen, fireEvent, createEvent, within } from '../../../tests/helpers/render'
|
||||
import BudgetCategoryTable from './BudgetPanelCategoryTable'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
const contribFor = vi.fn((_id: number) => [] as unknown[])
|
||||
|
||||
vi.mock('../Plugins/PluginContributions', () => ({
|
||||
usePluginViewContributions: () => contribFor,
|
||||
PluginCardFooter: ({ items }: { items: unknown[] }) => <div data-testid="plugin-footer">{items.length}</div>,
|
||||
}))
|
||||
|
||||
const TRIP_MEMBERS: TripMember[] = [
|
||||
{ id: 1, username: 'ada', avatar_url: null },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
function budgetItem(overrides: Partial<BudgetItem> = {}): BudgetItem {
|
||||
return {
|
||||
id: 1, trip_id: 7, category: 'Food', name: 'Ferry', total_price: 100,
|
||||
persons: 2, days: 5, note: null, expense_date: null, reservation_id: null, members: [],
|
||||
...overrides,
|
||||
} as unknown as BudgetItem
|
||||
}
|
||||
|
||||
type Props = Parameters<typeof BudgetCategoryTable>[0]
|
||||
|
||||
function setup(overrides: Partial<Props> = {}, items: BudgetItem[] = [budgetItem()]) {
|
||||
const spies = {
|
||||
setEditingCat: vi.fn(),
|
||||
setDragCat: vi.fn(),
|
||||
setDragOverCat: vi.fn(),
|
||||
setDragItem: vi.fn(),
|
||||
setDragOverItem: vi.fn(),
|
||||
setDragItemCat: vi.fn(),
|
||||
reorderBudgetCategories: vi.fn(async () => {}),
|
||||
reorderBudgetItems: vi.fn(async () => {}),
|
||||
handleRenameCategory: vi.fn(async () => {}),
|
||||
handleDeleteCategory: vi.fn(async () => {}),
|
||||
handleDeleteItem: vi.fn(async () => {}),
|
||||
handleUpdateField: vi.fn(async () => {}),
|
||||
handleAddItem: vi.fn(async () => {}),
|
||||
setBudgetItemMembers: vi.fn(async () => ({ members: [], item: {} })),
|
||||
toggleBudgetMemberPaid: vi.fn(async () => {}),
|
||||
}
|
||||
const props = {
|
||||
cat: 'Food',
|
||||
grouped: new Map([['Food', items]]),
|
||||
categoryColor: () => '#ef4444',
|
||||
canEdit: true,
|
||||
editingCat: null,
|
||||
dragCat: null,
|
||||
dragOverCat: null,
|
||||
dragItem: null,
|
||||
dragOverItem: null,
|
||||
dragItemCat: null,
|
||||
categoryNames: ['Transport', 'Food', 'Hotels'],
|
||||
tripId: 7,
|
||||
currency: 'EUR',
|
||||
locale: 'en-US',
|
||||
t: (key: string) => key,
|
||||
fmt: (v: number | null | undefined, cur: string) => `${v ?? '-'} ${cur}`,
|
||||
hasMultipleMembers: false,
|
||||
tripMembers: TRIP_MEMBERS,
|
||||
th: {} as CSSProperties,
|
||||
td: {} as CSSProperties,
|
||||
...spies,
|
||||
...overrides,
|
||||
} as unknown as Props
|
||||
|
||||
const utils = render(<BudgetCategoryTable {...props} />)
|
||||
return { ...spies, ...utils }
|
||||
}
|
||||
|
||||
/** dragleave carrying a relatedTarget — jsdom lacks DragEvent, so fireEvent drops it. */
|
||||
function dragLeaveInto(target: Element, relatedTarget: Element) {
|
||||
const event = createEvent.dragLeave(target)
|
||||
Object.defineProperty(event, 'relatedTarget', { value: relatedTarget })
|
||||
fireEvent(target, event)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
contribFor.mockReset()
|
||||
contribFor.mockReturnValue([])
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — header', () => {
|
||||
it('FE-W4BCT-001: shows the category name and the summed subtotal', () => {
|
||||
setup({}, [budgetItem(), budgetItem({ id: 2, total_price: 50 })])
|
||||
|
||||
expect(screen.getByText('Food')).toBeInTheDocument()
|
||||
expect(screen.getByText('150 EUR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-002: treats a priceless item as zero in the subtotal', () => {
|
||||
const { container } = setup({}, [budgetItem({ total_price: null } as Partial<BudgetItem>)])
|
||||
|
||||
// The subtotal sits in the black category header, before the table.
|
||||
expect(container.querySelectorAll('span')[1]).toHaveTextContent('0 EUR')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-003: renders an empty category with only the add row', () => {
|
||||
setup({ grouped: new Map() } as Partial<Props>)
|
||||
|
||||
expect(screen.getByPlaceholderText('budget.newEntry')).toBeInTheDocument()
|
||||
expect(screen.queryByDisplayValue('Ferry')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-004: the pencil starts renaming the category', () => {
|
||||
const { setEditingCat } = setup()
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
|
||||
expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Food' })
|
||||
})
|
||||
|
||||
it('FE-W4BCT-005: Enter in the rename input commits and closes the editor', () => {
|
||||
const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
|
||||
const input = screen.getByDisplayValue('Groceries')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries')
|
||||
expect(setEditingCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-006: blurring the rename input commits it', () => {
|
||||
const { handleRenameCategory } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
|
||||
|
||||
fireEvent.blur(screen.getByDisplayValue('Groceries'))
|
||||
|
||||
expect(handleRenameCategory).toHaveBeenCalledWith('Food', 'Groceries')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-007: Escape abandons the rename', () => {
|
||||
const { handleRenameCategory, setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Groceries' } })
|
||||
|
||||
fireEvent.keyDown(screen.getByDisplayValue('Groceries'), { key: 'Escape' })
|
||||
|
||||
expect(handleRenameCategory).not.toHaveBeenCalled()
|
||||
expect(setEditingCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-008: typing updates the pending rename value', () => {
|
||||
const { setEditingCat } = setup({ editingCat: { name: 'Food', value: 'Food' } })
|
||||
|
||||
fireEvent.change(screen.getByDisplayValue('Food'), { target: { value: 'Fuel' } })
|
||||
|
||||
expect(setEditingCat).toHaveBeenCalledWith({ name: 'Food', value: 'Fuel' })
|
||||
})
|
||||
|
||||
it('FE-W4BCT-009: the trash button deletes the category', () => {
|
||||
const { handleDeleteCategory } = setup()
|
||||
|
||||
fireEvent.click(screen.getByTitle('budget.deleteCategory'))
|
||||
|
||||
expect(handleDeleteCategory).toHaveBeenCalledWith('Food')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-010: a read-only table hides every editing affordance', () => {
|
||||
setup({ canEdit: false })
|
||||
|
||||
expect(screen.queryByTitle('budget.deleteCategory')).toBeNull()
|
||||
expect(screen.queryByTitle('common.delete')).toBeNull()
|
||||
expect(screen.queryByPlaceholderText('budget.newEntry')).toBeNull()
|
||||
expect(document.querySelectorAll('[draggable="true"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — rows', () => {
|
||||
it('FE-W4BCT-011: derives the per-person, per-day and per-person-day figures', () => {
|
||||
setup({}, [budgetItem({ total_price: 100, persons: 2, days: 5 })])
|
||||
|
||||
expect(screen.getByText('50 EUR')).toBeInTheDocument()
|
||||
expect(screen.getByText('20 EUR')).toBeInTheDocument()
|
||||
expect(screen.getByText('10 EUR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-012: blanks the per-person columns for a custom member split', () => {
|
||||
setup({}, [budgetItem({
|
||||
total_price: 100, persons: 2, days: 5,
|
||||
members: [{ user_id: 1, username: 'ada', amount: 70 }, { user_id: 2, username: 'bob', amount: 30 }],
|
||||
} as unknown as Partial<BudgetItem>)])
|
||||
|
||||
// per-day still resolves; per-person and per-person-day are dashed out.
|
||||
expect(screen.getByText('20 EUR')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-013: deleting a row reports its id', () => {
|
||||
const { handleDeleteItem } = setup()
|
||||
|
||||
fireEvent.click(screen.getByTitle('common.delete'))
|
||||
|
||||
expect(handleDeleteItem).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-014: editing the name cell saves through handleUpdateField', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('Ferry'))
|
||||
const input = screen.getByDisplayValue('Ferry')
|
||||
fireEvent.change(input, { target: { value: 'Bus' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'name', 'Bus')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-015: a reservation-linked row locks the name cell', () => {
|
||||
setup({}, [budgetItem({ reservation_id: 42 } as Partial<BudgetItem>)])
|
||||
|
||||
fireEvent.click(screen.getByText('Ferry'))
|
||||
|
||||
expect(screen.queryByDisplayValue('Ferry')).toBeNull()
|
||||
expect(screen.getByText('Ferry')).not.toHaveAttribute('title')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-016: the persons cell coerces the entry to an integer', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('2'))
|
||||
const input = screen.getByDisplayValue('2')
|
||||
fireEvent.change(input, { target: { value: '4.7' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', 4)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-017: clearing the days cell stores null', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('5'))
|
||||
const input = screen.getByDisplayValue('5')
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-018: swaps the persons cell for member chips on a multi-member trip', () => {
|
||||
setup({ hasMultipleMembers: true }, [budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial<BudgetItem>)])
|
||||
|
||||
// One chip in the persons column and one in the mobile stack under the name.
|
||||
expect(screen.getAllByText('A')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-019: shows the raw expense date instead of a picker when read-only', () => {
|
||||
setup({ canEdit: false }, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
|
||||
|
||||
expect(screen.getByText('2026-06-15')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-020: falls back to an em dash for a read-only row without a date', () => {
|
||||
setup({ canEdit: false })
|
||||
|
||||
expect(screen.getByText('—')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-021: appends a plugin footer row when a plugin contributes', () => {
|
||||
contribFor.mockReturnValue([{ kind: 'column' }])
|
||||
setup()
|
||||
|
||||
expect(screen.getByTestId('plugin-footer')).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-022: adding an item routes the payload into the category', () => {
|
||||
const { handleAddItem } = setup()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('budget.newEntry'), { target: { value: 'Taxi' } })
|
||||
fireEvent.click(screen.getByTitle('reservations.add'))
|
||||
|
||||
expect(handleAddItem).toHaveBeenCalledWith('Food', expect.objectContaining({ name: 'Taxi' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — drag and drop', () => {
|
||||
it('FE-W4BCT-023: the category handle starts and ends a category drag', () => {
|
||||
const { setDragCat, setDragOverCat, container } = setup()
|
||||
const handle = container.querySelectorAll('[draggable="true"]')[0] as HTMLElement
|
||||
|
||||
fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
expect(setDragCat).toHaveBeenCalledWith('Food')
|
||||
|
||||
fireEvent.dragEnd(handle)
|
||||
expect(setDragCat).toHaveBeenLastCalledWith(null)
|
||||
expect(setDragOverCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-024: dragging another category over this one marks the drop line', () => {
|
||||
const { setDragOverCat, container } = setup({ dragCat: 'Transport' })
|
||||
|
||||
fireEvent.dragOver(container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
|
||||
|
||||
expect(setDragOverCat).toHaveBeenCalledWith('Food')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-025: ignores a drag-over from the same category or from an item', () => {
|
||||
const same = setup({ dragCat: 'Food' })
|
||||
fireEvent.dragOver(same.container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
|
||||
expect(same.setDragOverCat).not.toHaveBeenCalled()
|
||||
|
||||
const item = setup({ dragCat: 'Transport', dragItem: 5 })
|
||||
fireEvent.dragOver(item.container.firstElementChild!, { dataTransfer: { dropEffect: '' } })
|
||||
expect(item.setDragOverCat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-026: dropping a category reorders the list around this one', () => {
|
||||
const { reorderBudgetCategories, setDragCat, container } = setup({ dragCat: 'Hotels' })
|
||||
|
||||
fireEvent.drop(container.firstElementChild!)
|
||||
|
||||
expect(reorderBudgetCategories).toHaveBeenCalledWith(7, ['Transport', 'Hotels', 'Food'])
|
||||
expect(setDragCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-027: dropping a category on itself only clears the drag state', () => {
|
||||
const { reorderBudgetCategories, setDragOverCat, container } = setup({ dragCat: 'Food' })
|
||||
|
||||
fireEvent.drop(container.firstElementChild!)
|
||||
|
||||
expect(reorderBudgetCategories).not.toHaveBeenCalled()
|
||||
expect(setDragOverCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-028: leaving the category clears the drop marker', () => {
|
||||
const { setDragOverCat, container } = setup({ dragCat: 'Transport' })
|
||||
|
||||
fireEvent.dragLeave(container.firstElementChild!, { relatedTarget: document.body })
|
||||
|
||||
expect(setDragOverCat).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-029: dragging a row over a sibling marks it as the target', () => {
|
||||
const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' })]
|
||||
const { setDragOverItem, container } = setup({ dragItem: 2, dragItemCat: 'Food' }, rows)
|
||||
|
||||
fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: { dropEffect: '' } })
|
||||
|
||||
expect(setDragOverItem).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-030: dropping a row reorders the ids inside the category', () => {
|
||||
const rows = [budgetItem(), budgetItem({ id: 2, name: 'Bus' }), budgetItem({ id: 3, name: 'Taxi' })]
|
||||
const { reorderBudgetItems, setDragItem, container } = setup({ dragItem: 3, dragItemCat: 'Food' }, rows)
|
||||
|
||||
fireEvent.drop(container.querySelectorAll('tbody tr')[0])
|
||||
|
||||
expect(reorderBudgetItems).toHaveBeenCalledWith(7, [3, 1, 2])
|
||||
expect(setDragItem).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-031: a row from another category is not reordered here', () => {
|
||||
const { reorderBudgetItems, container } = setup({ dragItem: 9, dragItemCat: 'Transport' })
|
||||
|
||||
fireEvent.drop(container.querySelectorAll('tbody tr')[0])
|
||||
|
||||
expect(reorderBudgetItems).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-032: the row handle starts and ends an item drag', () => {
|
||||
const { setDragItem, setDragItemCat, container } = setup()
|
||||
const handle = container.querySelectorAll('[draggable="true"]')[1] as HTMLElement
|
||||
|
||||
fireEvent.dragStart(handle, { dataTransfer: { effectAllowed: '' } })
|
||||
expect(setDragItem).toHaveBeenCalledWith(1)
|
||||
expect(setDragItemCat).toHaveBeenCalledWith('Food')
|
||||
|
||||
fireEvent.dragEnd(handle)
|
||||
expect(setDragItem).toHaveBeenLastCalledWith(null)
|
||||
expect(setDragItemCat).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-033: leaving a row clears the item drop marker', () => {
|
||||
const { setDragOverItem, container } = setup()
|
||||
|
||||
fireEvent.dragLeave(container.querySelectorAll('tbody tr')[0], { relatedTarget: document.body })
|
||||
|
||||
expect(setDragOverItem).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-034: dims the dragged category and marks the drop line', () => {
|
||||
const dragged = setup({ dragCat: 'Food' })
|
||||
expect((dragged.container.firstElementChild as HTMLElement).style.opacity).toBe('0.4')
|
||||
|
||||
const over = setup({ dragCat: 'Transport', dragOverCat: 'Food' })
|
||||
const marker = within(over.container.firstElementChild as HTMLElement).getAllByRole('generic')
|
||||
expect(marker.length).toBeGreaterThan(0)
|
||||
expect((over.container.firstElementChild as HTMLElement).firstElementChild).toHaveStyle({ height: '4px' })
|
||||
})
|
||||
|
||||
it('FE-W4BCT-035: dims the dragged row and outlines the drop target', () => {
|
||||
const dragged = setup({ dragItem: 1 })
|
||||
expect((dragged.container.querySelector('tbody tr') as HTMLElement).style.opacity).toBe('0.4')
|
||||
|
||||
const over = setup({ dragOverItem: 1 })
|
||||
expect((over.container.querySelector('tbody tr') as HTMLElement).style.boxShadow).toBe('inset 4px 0 0 0 var(--accent)')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-036: the table body accepts a category drop only while one is being dragged', () => {
|
||||
const dragging = setup({ dragCat: 'Transport' })
|
||||
const activeTransfer = { dropEffect: '' }
|
||||
fireEvent.dragOver(dragging.container.querySelector('table')!.parentElement!, { dataTransfer: activeTransfer })
|
||||
expect(activeTransfer.dropEffect).toBe('move')
|
||||
|
||||
const idle = setup()
|
||||
const idleTransfer = { dropEffect: '' }
|
||||
fireEvent.dragOver(idle.container.querySelector('table')!.parentElement!, { dataTransfer: idleTransfer })
|
||||
expect(idleTransfer.dropEffect).toBe('')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-037: a row accepts a dragged category without becoming an item drop target', () => {
|
||||
const { setDragOverItem, container } = setup({ dragCat: 'Transport' })
|
||||
const transfer = { dropEffect: '' }
|
||||
|
||||
fireEvent.dragOver(container.querySelectorAll('tbody tr')[0], { dataTransfer: transfer })
|
||||
|
||||
expect(transfer.dropEffect).toBe('move')
|
||||
expect(setDragOverItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-038: moving between a row and its own cells keeps the drop marker', () => {
|
||||
const { setDragOverItem, setDragOverCat, container } = setup({ dragCat: 'Transport', dragItem: 2, dragItemCat: 'Food' })
|
||||
const row = container.querySelectorAll('tbody tr')[0]
|
||||
|
||||
// jsdom has no DragEvent, so relatedTarget has to be attached by hand.
|
||||
dragLeaveInto(row, row.querySelector('td')!)
|
||||
dragLeaveInto(container.firstElementChild as HTMLElement, container.querySelector('table')!)
|
||||
|
||||
expect(setDragOverItem).not.toHaveBeenCalled()
|
||||
expect(setDragOverCat).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — hover affordances', () => {
|
||||
it('FE-W4BCT-039: the rename pencil brightens while hovered', () => {
|
||||
setup()
|
||||
const pencil = screen.getAllByRole('button')[0]
|
||||
|
||||
fireEvent.mouseEnter(pencil)
|
||||
expect(pencil.style.color).toBe('rgb(255, 255, 255)')
|
||||
|
||||
fireEvent.mouseLeave(pencil)
|
||||
expect(pencil.style.color).toBe('rgba(255, 255, 255, 0.4)')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-040: the delete-category button fades in while hovered', () => {
|
||||
setup()
|
||||
const trash = screen.getByTitle('budget.deleteCategory')
|
||||
|
||||
fireEvent.mouseEnter(trash)
|
||||
expect(trash.style.opacity).toBe('1')
|
||||
|
||||
fireEvent.mouseLeave(trash)
|
||||
expect(trash.style.opacity).toBe('0.6')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-041: a row highlights while hovered', () => {
|
||||
const { container } = setup()
|
||||
const row = container.querySelector('tbody tr') as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(row.style.background).toBe('var(--bg-hover)')
|
||||
|
||||
fireEvent.mouseLeave(row)
|
||||
expect(row.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-042: the delete-row button turns red while hovered', () => {
|
||||
setup()
|
||||
const trash = screen.getByTitle('common.delete')
|
||||
|
||||
fireEvent.mouseEnter(trash)
|
||||
expect(trash.style.color).toBe('rgb(239, 68, 68)')
|
||||
|
||||
fireEvent.mouseLeave(trash)
|
||||
expect(trash.style.color).toBe('rgb(209, 213, 219)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — remaining cells', () => {
|
||||
it('FE-W4BCT-043: editing the total saves the parsed number', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('100.00'))
|
||||
const input = screen.getByDisplayValue('100')
|
||||
fireEvent.change(input, { target: { value: '120,50' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'total_price', 120.5)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-044: a zero-decimal currency drops the decimals from the amount placeholder', () => {
|
||||
setup({ currency: 'JPY' }, [budgetItem({ total_price: null } as Partial<BudgetItem>)])
|
||||
|
||||
expect(screen.getByText('0')).toBeInTheDocument()
|
||||
expect(screen.queryByText('0,00')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BCT-045: editing the note saves it', () => {
|
||||
const { handleUpdateField } = setup({}, [budgetItem({ note: 'Return trip' } as Partial<BudgetItem>)])
|
||||
|
||||
fireEvent.click(screen.getByText('Return trip'))
|
||||
const input = screen.getByDisplayValue('Return trip')
|
||||
fireEvent.change(input, { target: { value: 'One way' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'note', 'One way')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-046: picking a day from the date cell stores the ISO date', () => {
|
||||
const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
|
||||
const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7]
|
||||
|
||||
fireEvent.click(dateCell.querySelector('button')!)
|
||||
const day20 = screen.getAllByRole('button').find(b => b.textContent?.trim() === '20')
|
||||
fireEvent.click(day20!)
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', '2026-06-20')
|
||||
})
|
||||
|
||||
it('FE-W4BCT-047: clearing the date cell stores null', () => {
|
||||
const { handleUpdateField, container } = setup({}, [budgetItem({ expense_date: '2026-06-15' } as Partial<BudgetItem>)])
|
||||
const dateCell = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')[7]
|
||||
|
||||
fireEvent.click(dateCell.querySelector('button')!)
|
||||
fireEvent.click(screen.getByLabelText('Clear date'))
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'expense_date', null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-048: a non-numeric persons entry stores null', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('2'))
|
||||
const input = screen.getByDisplayValue('2')
|
||||
fireEvent.change(input, { target: { value: 'abc' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'persons', null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-049: zero days is stored as null rather than 0', () => {
|
||||
const { handleUpdateField } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('5'))
|
||||
const input = screen.getByDisplayValue('5')
|
||||
fireEvent.change(input, { target: { value: '0' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(handleUpdateField).toHaveBeenCalledWith(1, 'days', null)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-050: an item without persons, days or members dashes out every derived column', () => {
|
||||
const { container } = setup({}, [budgetItem({ persons: null, days: null, members: undefined } as unknown as Partial<BudgetItem>)])
|
||||
const cells = container.querySelectorAll('tbody tr')[0].querySelectorAll('td')
|
||||
|
||||
// per person, per day, per person-day — none of them is derivable.
|
||||
expect(cells[4]).toHaveTextContent('-')
|
||||
expect(cells[5]).toHaveTextContent('-')
|
||||
expect(cells[6]).toHaveTextContent('-')
|
||||
expect(screen.queryByText('100.00 EUR')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetCategoryTable — member chips', () => {
|
||||
const withMembers = () => setup(
|
||||
{ hasMultipleMembers: true },
|
||||
[budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 0 }] } as unknown as Partial<BudgetItem>)],
|
||||
)
|
||||
|
||||
it('FE-W4BCT-051: tapping a chip toggles that member’s paid flag', () => {
|
||||
const { toggleBudgetMemberPaid } = withMembers()
|
||||
|
||||
// The chip is rendered twice: the mobile stack under the name and the persons column.
|
||||
const chips = screen.getAllByText('A')
|
||||
fireEvent.click(chips[0])
|
||||
fireEvent.click(chips[1])
|
||||
|
||||
expect(toggleBudgetMemberPaid).toHaveBeenCalledTimes(2)
|
||||
expect(toggleBudgetMemberPaid).toHaveBeenCalledWith(7, 1, 1, true)
|
||||
})
|
||||
|
||||
it('FE-W4BCT-052: picking a member from either chip dropdown sets the item members', () => {
|
||||
const { setBudgetItemMembers, container } = withMembers()
|
||||
// The mobile stack under the name and the persons column both carry an editor.
|
||||
const editors = container.querySelectorAll('td button')
|
||||
|
||||
fireEvent.click(editors[0])
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
fireEvent.click(editors[0]) // picking an option leaves the dropdown open
|
||||
fireEvent.click(editors[1])
|
||||
fireEvent.click(screen.getByText('bob'))
|
||||
|
||||
expect(setBudgetItemMembers).toHaveBeenCalledTimes(2)
|
||||
expect(setBudgetItemMembers).toHaveBeenCalledWith(7, 1, [1, 2])
|
||||
})
|
||||
|
||||
it('FE-W4BCT-053: read-only member chips expose no editing controls', () => {
|
||||
setup(
|
||||
{ hasMultipleMembers: true, canEdit: false },
|
||||
[budgetItem({ members: [{ user_id: 1, username: 'ada', paid: 1 }] } as unknown as Partial<BudgetItem>)],
|
||||
)
|
||||
|
||||
expect(screen.getAllByText('A')).toHaveLength(2)
|
||||
expect(document.querySelectorAll('table button')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
// FE-W4IEC-001 to FE-W4IEC-016
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import InlineEditCell from './BudgetPanelInlineEditCell'
|
||||
|
||||
function setup(props: Partial<Parameters<typeof InlineEditCell>[0]> = {}) {
|
||||
const onSave = vi.fn()
|
||||
const utils = render(<InlineEditCell value="Ferry" onSave={onSave} locale="en-US" {...props} />)
|
||||
return { onSave, ...utils }
|
||||
}
|
||||
|
||||
function paste(input: HTMLElement, text: string) {
|
||||
fireEvent.paste(input, { clipboardData: { getData: () => text } })
|
||||
}
|
||||
|
||||
describe('InlineEditCell — display', () => {
|
||||
it('FE-W4IEC-001: shows the raw text value', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Ferry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-002: formats a number with the given decimals and locale', () => {
|
||||
setup({ value: 1234.5, type: 'number' })
|
||||
expect(screen.getByText('1,234.50')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-003: honours a custom decimal count', () => {
|
||||
setup({ value: 12, type: 'number', decimals: 0 })
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-004: falls back to the placeholder, then to a dash', () => {
|
||||
const { unmount } = setup({ value: null, placeholder: 'Add note' })
|
||||
expect(screen.getByText('Add note')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
setup({ value: null })
|
||||
expect(screen.getByText('-')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-005: exposes the edit tooltip and hover feedback when editable', () => {
|
||||
const { container } = setup({ editTooltip: 'Click to edit' })
|
||||
const cell = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(cell).toHaveAttribute('title', 'Click to edit')
|
||||
fireEvent.mouseEnter(cell)
|
||||
expect(cell.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(cell)
|
||||
expect(cell.style.background).toBe('transparent')
|
||||
})
|
||||
|
||||
it('FE-W4IEC-006: a read-only cell has no tooltip, no hover and cannot be opened', () => {
|
||||
const { container } = setup({ readOnly: true, editTooltip: 'Click to edit' })
|
||||
const cell = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(cell).not.toHaveAttribute('title')
|
||||
fireEvent.mouseEnter(cell)
|
||||
expect(cell.style.background).toBe('')
|
||||
|
||||
fireEvent.click(cell)
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-007: centres the content when the caller centres the text', () => {
|
||||
const { container } = setup({ style: { textAlign: 'center' } })
|
||||
|
||||
expect((container.firstElementChild as HTMLElement).style.justifyContent).toBe('center')
|
||||
})
|
||||
})
|
||||
|
||||
describe('InlineEditCell — editing', () => {
|
||||
it('FE-W4IEC-008: clicking opens a focused, pre-selected input', () => {
|
||||
const { container } = setup()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
expect(input).toHaveValue('Ferry')
|
||||
expect(input).toHaveFocus()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-009: Enter saves the changed value', () => {
|
||||
const { onSave, container } = setup()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'Bus' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('Bus')
|
||||
expect(screen.queryByRole('textbox')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-010: blur saves and an unchanged value does not fire onSave', () => {
|
||||
const { onSave, container } = setup()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
fireEvent.blur(screen.getByRole('textbox'))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-011: Escape restores the original value without saving', () => {
|
||||
const { onSave, container } = setup()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'Bus' } })
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Ferry')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4IEC-012: a numeric cell parses a comma decimal and uses a decimal keypad', () => {
|
||||
const { onSave, container } = setup({ value: 10, type: 'number' })
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
expect(input).toHaveAttribute('inputmode', 'decimal')
|
||||
fireEvent.change(input, { target: { value: '12,50' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(12.5)
|
||||
})
|
||||
|
||||
it('FE-W4IEC-013: an unparseable numeric entry saves null', () => {
|
||||
const { onSave, container } = setup({ value: 10, type: 'number' })
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'abc' } })
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4IEC-014: pasting a formatted amount normalizes separators', () => {
|
||||
const { container } = setup({ value: 0, type: 'number' })
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
paste(input, '1.234,56 EUR')
|
||||
expect(input).toHaveValue('1234.56')
|
||||
|
||||
paste(input, '$2,345.67')
|
||||
expect(input).toHaveValue('2345.67')
|
||||
})
|
||||
|
||||
it('FE-W4IEC-015: pasting a separator-free amount keeps the digits', () => {
|
||||
const { container } = setup({ value: 0, type: 'number' })
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
paste(input, 'EUR 4200')
|
||||
expect(input).toHaveValue('4200')
|
||||
})
|
||||
|
||||
it('FE-W4IEC-016: a text cell leaves pasted content to the browser', () => {
|
||||
const { container } = setup()
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
paste(input, '1.234,56')
|
||||
|
||||
expect(input).toHaveValue('Ferry')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { normalizePastedAmount } from './BudgetPanel.helpers'
|
||||
|
||||
interface InlineEditCellProps {
|
||||
value: string | number | null | undefined
|
||||
@@ -29,21 +30,7 @@ export default function InlineEditCell({ value, onSave, type = 'text', style = {
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
if (type !== 'number') return
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData.getData('text').trim()
|
||||
// Strip everything except digits, dots, commas, minus
|
||||
text = text.replace(/[^\d.,-]/g, '')
|
||||
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
|
||||
const lastComma = text.lastIndexOf(',')
|
||||
const lastDot = text.lastIndexOf('.')
|
||||
const decimalPos = Math.max(lastComma, lastDot)
|
||||
if (decimalPos > -1) {
|
||||
const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
|
||||
const decPart = text.substring(decimalPos + 1)
|
||||
text = intPart + '.' + decPart
|
||||
} else {
|
||||
text = text.replace(/[.,]/g, '')
|
||||
}
|
||||
setEditValue(text)
|
||||
setEditValue(normalizePastedAmount(e.clipboardData.getData('text')))
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// FE-W4BMC-001 to FE-W4BMC-016
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import type { BudgetItemMember } from '../../types'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import BudgetMemberChips, { ChipWithTooltip, type TripMember } from './BudgetPanelMemberChips'
|
||||
|
||||
const TRIP_MEMBERS: TripMember[] = [
|
||||
{ id: 1, username: 'ada', avatar_url: '/uploads/avatars/ada.png' },
|
||||
{ id: 2, username: 'bob', avatar_url: null },
|
||||
]
|
||||
|
||||
function member(overrides: Partial<BudgetItemMember> = {}): BudgetItemMember {
|
||||
return { user_id: 1, username: 'ada', avatar_url: null, paid: 0, ...overrides } as unknown as BudgetItemMember
|
||||
}
|
||||
|
||||
function setup(props: Partial<Parameters<typeof BudgetMemberChips>[0]> = {}) {
|
||||
const onSetMembers = vi.fn()
|
||||
const onTogglePaid = vi.fn()
|
||||
const utils = render(
|
||||
<BudgetMemberChips members={[member()]} tripMembers={TRIP_MEMBERS} onSetMembers={onSetMembers} onTogglePaid={onTogglePaid} {...props} />,
|
||||
)
|
||||
return { onSetMembers, onTogglePaid, ...utils }
|
||||
}
|
||||
|
||||
describe('ChipWithTooltip', () => {
|
||||
it('FE-W4BMC-001: falls back to the uppercased initial', () => {
|
||||
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
|
||||
|
||||
expect(container.firstElementChild).toHaveTextContent('A')
|
||||
})
|
||||
|
||||
it('FE-W4BMC-002: renders the avatar when one is given', () => {
|
||||
const { container } = render(<ChipWithTooltip label="ada" avatarUrl="/uploads/avatars/ada.png" />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||||
})
|
||||
|
||||
it('FE-W4BMC-003: hovering portals a name tooltip and leaving removes it', () => {
|
||||
const { container } = render(<ChipWithTooltip label="Ada Lovelace" avatarUrl={null} />)
|
||||
const chip = container.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(chip)
|
||||
expect(screen.getByText('Ada Lovelace')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(chip)
|
||||
expect(screen.queryByText('Ada Lovelace')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-004: a paid chip turns green and the tooltip carries a Paid tag', () => {
|
||||
const { container } = render(<ChipWithTooltip label="ada" avatarUrl={null} paid />)
|
||||
const chip = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(chip.style.border).toBe('2px solid rgb(34, 197, 94)')
|
||||
fireEvent.mouseEnter(chip)
|
||||
expect(screen.getByText('Paid')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-005: only a clickable chip gets the pointer cursor', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container, unmount } = render(<ChipWithTooltip label="ada" avatarUrl={null} onClick={onClick} />)
|
||||
const chip = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(chip.style.cursor).toBe('pointer')
|
||||
fireEvent.click(chip)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
unmount()
|
||||
|
||||
const plain = render(<ChipWithTooltip label="ada" avatarUrl={null} />)
|
||||
expect((plain.container.firstElementChild as HTMLElement).style.cursor).toBe('default')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BudgetMemberChips', () => {
|
||||
it('FE-W4BMC-006: renders one chip per assigned member plus the picker button', () => {
|
||||
setup({ members: [member(), member({ user_id: 2, username: 'bob' })] })
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument()
|
||||
expect(screen.getByText('B')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-007: uses the people icon while nobody is assigned and the pencil afterwards', () => {
|
||||
const { container, unmount } = setup({ members: [] })
|
||||
expect(container.querySelector('.lucide-users')).not.toBeNull()
|
||||
unmount()
|
||||
|
||||
const withMembers = setup()
|
||||
expect(withMembers.container.querySelector('.lucide-pencil')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-008: clicking a chip toggles that member paid flag', () => {
|
||||
const { onTogglePaid } = setup()
|
||||
|
||||
fireEvent.click(screen.getByText('A'))
|
||||
|
||||
expect(onTogglePaid).toHaveBeenCalledWith(1, true)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-009: clicking an already-paid chip clears the flag', () => {
|
||||
const { onTogglePaid } = setup({ members: [member({ paid: 1 })] })
|
||||
|
||||
fireEvent.click(screen.getByText('A'))
|
||||
|
||||
expect(onTogglePaid).toHaveBeenCalledWith(1, false)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-010: a read-only strip has no picker and no paid toggling', () => {
|
||||
const { onTogglePaid } = setup({ readOnly: true })
|
||||
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
fireEvent.click(screen.getByText('A'))
|
||||
expect(onTogglePaid).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4BMC-011: the picker lists every trip member and marks the assigned ones', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
const rows = screen.getAllByRole('button').slice(1)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]).toHaveTextContent('ada')
|
||||
expect(rows[0].querySelector('.lucide-check')).not.toBeNull()
|
||||
expect(rows[1].querySelector('.lucide-check')).toBeNull()
|
||||
expect(rows[0].querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||||
expect(rows[1]).toHaveTextContent('B')
|
||||
})
|
||||
|
||||
it('FE-W4BMC-012: picking an unassigned member adds them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([1, 2])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-013: picking an assigned member removes them', () => {
|
||||
const { onSetMembers } = setup()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[1])
|
||||
|
||||
expect(onSetMembers).toHaveBeenCalledWith([])
|
||||
})
|
||||
|
||||
it('FE-W4BMC-014: a mousedown outside closes the picker, inside keeps it', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
|
||||
fireEvent.mouseDown(screen.getAllByRole('button')[1])
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
|
||||
fireEvent.mouseDown(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-015: the trigger toggles the picker closed again', () => {
|
||||
setup()
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W4BMC-016: the non-compact variant renders larger chips', () => {
|
||||
setup({ compact: false })
|
||||
|
||||
expect(screen.getByText('A')).toHaveStyle({ width: '30px' })
|
||||
expect((screen.getByRole('button') as HTMLElement).style.width).toBe('28px')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-W4PIE-001 to FE-W4PIE-006
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import PieChart from './BudgetPanelPieChart'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ label: 'Food', value: 300, color: '#ef4444' },
|
||||
{ label: 'Hotels', value: 100, color: '#3b82f6' },
|
||||
]
|
||||
|
||||
describe('BudgetPanelPieChart', () => {
|
||||
it('FE-W4PIE-001: renders nothing without segments', () => {
|
||||
const { container } = render(<PieChart segments={[]} totalLabel="1.200 €" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4PIE-002: renders nothing when every segment is zero', () => {
|
||||
const { container } = render(
|
||||
<PieChart segments={[{ label: 'Food', value: 0, color: '#ef4444' }]} totalLabel="0 €" />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4PIE-003: turns the segment shares into consecutive conic-gradient stops', () => {
|
||||
const { container } = render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
|
||||
const pie = container.querySelector('.trek-pie-reveal') as HTMLElement
|
||||
|
||||
expect(pie.style.background).toBe('conic-gradient(rgb(239, 68, 68) 0deg 270deg, rgb(59, 130, 246) 270deg 360deg)')
|
||||
})
|
||||
|
||||
it('FE-W4PIE-004: shows the total label in the donut hole', () => {
|
||||
render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
|
||||
|
||||
expect(screen.getByText('400 €')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4PIE-005: defaults to a 200px pie with a 55% hole', () => {
|
||||
const { container } = render(<PieChart segments={SEGMENTS} totalLabel="400 €" />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
const hole = screen.getByText('400 €').parentElement as HTMLElement
|
||||
|
||||
expect(root.style.width).toBe('200px')
|
||||
expect(Math.round(parseFloat(hole.style.width))).toBe(110)
|
||||
})
|
||||
|
||||
it('FE-W4PIE-006: scales pie and hole from the size prop', () => {
|
||||
const { container } = render(<PieChart segments={SEGMENTS} size={120} totalLabel="400 €" />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
const hole = screen.getByText('400 €').parentElement as HTMLElement
|
||||
|
||||
expect(root.style.height).toBe('120px')
|
||||
expect(Math.round(parseFloat(hole.style.height))).toBe(66)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@ import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -21,6 +22,7 @@ import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||
const n = members.length
|
||||
@@ -318,13 +320,8 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
const bom = ''
|
||||
const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9À-ɏ _-]/g, '').trim()
|
||||
a.download = `costs-${safeName}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(blob, `costs-${safeName}.csv`)
|
||||
}
|
||||
|
||||
// ── small presentational helpers ────────────────────────────────────────
|
||||
@@ -467,9 +464,13 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
|
||||
{dayBanner}
|
||||
{dayGroups.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||
</div>
|
||||
search ? (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
{t('costs.noMatch')}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState scene="costs" title={t('costs.emptyText')} />
|
||||
)
|
||||
) : dayGroups.map(g => {
|
||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
||||
return (
|
||||
@@ -758,7 +759,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<div className="text-content" style={{ fontSize: 'calc(18px * var(--fs-scale-subtitle, 1))', fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
{!unfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||
</div>
|
||||
@@ -1290,7 +1291,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
@@ -1373,8 +1374,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
{on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="decimal" placeholder="0.00" data-testid="payer-amount"
|
||||
value={payerAmounts[p.id] || ''}
|
||||
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
@@ -1438,8 +1439,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="decimal"
|
||||
placeholder="0.00"
|
||||
value={item.price}
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
value={localizeAmountInput(item.price, currency)}
|
||||
onValueChange={v => handleUpdateItemPrice(item.id, v)}
|
||||
className="text-content"
|
||||
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
|
||||
@@ -1521,7 +1522,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
on ? (
|
||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
|
||||
<input type="text" inputMode="decimal" placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
|
||||
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
|
||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||
</div>
|
||||
|
||||
@@ -124,9 +124,13 @@ describe('CollabChat', () => {
|
||||
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-009: shows hint text in empty state', async () => {
|
||||
it('FE-COMP-CHAT-009: shows guidance in empty state', async () => {
|
||||
render(<CollabChat {...defaultProps} />);
|
||||
await screen.findByText(/Share ideas, plans/i);
|
||||
// The empty state now renders the shared EmptyState: a chat-scene mascot
|
||||
// plus the single "Start the conversation" title (the separate hint
|
||||
// paragraph was dropped in the mobile rewrite).
|
||||
await screen.findByText('Start the conversation');
|
||||
expect(document.querySelector('svg.trek--chat')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-CHAT-010: chat container renders', () => {
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// FE-W5CCM-001 to FE-W5CCM-024
|
||||
// ChatMessages is a pure presentational component — every piece of state arrives
|
||||
// as a prop from useCollabChat, so the tests drive it directly instead of going
|
||||
// through CollabChat (that path is covered in CollabChat.test.tsx).
|
||||
|
||||
vi.mock('./CollabChatLinkPreview', () => ({
|
||||
LinkPreview: ({ url, onLoad }: { url: string; onLoad?: () => void }) => (
|
||||
<button type="button" data-testid={`preview-${url}`} onClick={() => onLoad?.()}>
|
||||
preview
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
|
||||
interface ChatMsg {
|
||||
id: number
|
||||
user_id: number
|
||||
username: string
|
||||
text: string
|
||||
created_at: string
|
||||
user_avatar?: string | null
|
||||
reply_text?: string | null
|
||||
reply_username?: string | null
|
||||
reply_to?: number | null
|
||||
reactions?: { emoji: string; count: number; users: { user_id: number; username: string }[] }[]
|
||||
_deleted?: boolean
|
||||
}
|
||||
|
||||
const currentUser = { id: 1, username: 'me' }
|
||||
|
||||
function buildMsg(overrides: Partial<ChatMsg> = {}): ChatMsg {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 2,
|
||||
username: 'alice',
|
||||
text: 'hello',
|
||||
created_at: '2025-06-01T10:00:00.000Z',
|
||||
reactions: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
interface Handles {
|
||||
setHoveredId: ReturnType<typeof vi.fn>
|
||||
setReplyTo: ReturnType<typeof vi.fn>
|
||||
setReactMenu: ReturnType<typeof vi.fn>
|
||||
handleDelete: ReturnType<typeof vi.fn>
|
||||
handleReact: ReturnType<typeof vi.fn>
|
||||
handleLoadMore: ReturnType<typeof vi.fn>
|
||||
scrollToBottom: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
function renderMessages(
|
||||
messages: ChatMsg[],
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Handles {
|
||||
const handles: Handles = {
|
||||
setHoveredId: vi.fn(),
|
||||
setReplyTo: vi.fn(),
|
||||
setReactMenu: vi.fn(),
|
||||
handleDelete: vi.fn(),
|
||||
handleReact: vi.fn(),
|
||||
handleLoadMore: vi.fn(),
|
||||
scrollToBottom: vi.fn(),
|
||||
}
|
||||
const props = {
|
||||
currentUser,
|
||||
tripId: 1,
|
||||
t: (key: string) => key,
|
||||
is12h: false,
|
||||
canEdit: true,
|
||||
messages,
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
loadingMore: false,
|
||||
hoveredId: null,
|
||||
deletingIds: new Set<number>(),
|
||||
scrollRef: { current: null },
|
||||
isAtBottom: { current: false },
|
||||
checkAtBottom: vi.fn(),
|
||||
isOwn: (m: ChatMsg) => String(m.user_id) === String(currentUser.id),
|
||||
isEmojiOnly: (text: string) => /^\p{Extended_Pictographic}$/u.test(text),
|
||||
...handles,
|
||||
...overrides,
|
||||
}
|
||||
render(<ChatMessages {...props} />)
|
||||
return handles
|
||||
}
|
||||
|
||||
describe('ChatMessages', () => {
|
||||
it('FE-W5CCM-001: renders the empty state when there are no messages', () => {
|
||||
renderMessages([])
|
||||
expect(screen.getByText('collab.chat.empty')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-002: the load-more button reports its loading state and calls back', () => {
|
||||
const { handleLoadMore } = renderMessages([buildMsg()], { hasMore: true })
|
||||
const btn = screen.getByRole('button', { name: 'collab.chat.loadMore' })
|
||||
fireEvent.click(btn)
|
||||
expect(handleLoadMore).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-W5CCM-003: the load-more button is disabled and shows an ellipsis while loading', () => {
|
||||
renderMessages([buildMsg()], { hasMore: true, loadingMore: true })
|
||||
const btn = screen.getByText('...').closest('button')
|
||||
expect(btn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-004: a deleted message renders the placeholder line with its author', () => {
|
||||
renderMessages([buildMsg({ _deleted: true })])
|
||||
expect(screen.getByText(/collab\.chat\.deletedMessage/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('hello')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-005: the deleted placeholder falls back to English when the key is missing', () => {
|
||||
renderMessages([buildMsg({ _deleted: true })], {
|
||||
t: (key: string) => (key === 'collab.chat.deletedMessage' ? '' : key),
|
||||
})
|
||||
expect(screen.getByText(/deleted a message/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-006: a message not at the end of its group keeps the rounded tail', () => {
|
||||
renderMessages([
|
||||
buildMsg({ id: 1, user_id: 1, username: 'me', text: 'first' }),
|
||||
buildMsg({ id: 2, user_id: 1, username: 'me', text: 'second' }),
|
||||
])
|
||||
const first = screen.getByText('first').closest('div[style]')!
|
||||
const last = screen.getByText('second').closest('div[style]')!
|
||||
expect(first.getAttribute('style')).toContain('border-radius: 18px 18px 18px 18px')
|
||||
expect(last.getAttribute('style')).toContain('border-radius: 18px 18px 4px 18px')
|
||||
})
|
||||
|
||||
it('FE-W5CCM-007: the avatar image is rendered for a foreign author who has one', () => {
|
||||
renderMessages([buildMsg({ user_avatar: '/uploads/avatars/alice.png' })])
|
||||
const avatar = document.querySelector('img[src="/uploads/avatars/alice.png"]')
|
||||
expect(avatar).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-008: the avatar initial falls back to a question mark without a username', () => {
|
||||
renderMessages([buildMsg({ username: '' })])
|
||||
expect(screen.getByText('?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-009: hovering a bubble reports the hovered id and clears it on leave', () => {
|
||||
const { setHoveredId } = renderMessages([buildMsg({ id: 7 })])
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
|
||||
fireEvent.mouseEnter(bubble)
|
||||
expect(setHoveredId).toHaveBeenCalledWith(7)
|
||||
fireEvent.mouseLeave(bubble)
|
||||
expect(setHoveredId).toHaveBeenLastCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W5CCM-010: the hover actions become visible for the hovered message only', () => {
|
||||
renderMessages(
|
||||
[buildMsg({ id: 1, text: 'one' }), buildMsg({ id: 2, text: 'two' })],
|
||||
{ hoveredId: 1 },
|
||||
)
|
||||
const actions = screen.getAllByTitle('collab.chat.reply').map(b => b.parentElement!)
|
||||
expect(actions[0].getAttribute('style')).toContain('opacity: 1')
|
||||
expect(actions[1].getAttribute('style')).toContain('opacity: 0')
|
||||
})
|
||||
|
||||
it('FE-W5CCM-011: right-clicking a bubble opens the reaction menu at the cursor', () => {
|
||||
const { setReactMenu } = renderMessages([buildMsg({ id: 9 })])
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
|
||||
fireEvent.contextMenu(bubble, { clientX: 120, clientY: 240 })
|
||||
expect(setReactMenu).toHaveBeenCalledWith({ msgId: 9, x: 120, y: 240 })
|
||||
})
|
||||
|
||||
it('FE-W5CCM-012: right-clicking does nothing without edit rights', () => {
|
||||
const { setReactMenu } = renderMessages([buildMsg()], { canEdit: false })
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]')!
|
||||
fireEvent.contextMenu(bubble, { clientX: 10, clientY: 20 })
|
||||
expect(setReactMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-013: a single tap only records the tap, a double tap opens the reaction menu', () => {
|
||||
const { setReactMenu } = renderMessages([buildMsg({ id: 4 })])
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 5, clientY: 6 }] })
|
||||
expect(setReactMenu).not.toHaveBeenCalled()
|
||||
expect(bubble.dataset.lastTap).toBeTruthy()
|
||||
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 33, clientY: 44 }] })
|
||||
expect(setReactMenu).toHaveBeenCalledWith({ msgId: 4, x: 33, y: 44 })
|
||||
})
|
||||
|
||||
it('FE-W5CCM-014: a double tap without touch coordinates does not open the menu', () => {
|
||||
const { setReactMenu } = renderMessages([buildMsg()])
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [] })
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [] })
|
||||
expect(setReactMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-015: a double tap is ignored without edit rights', () => {
|
||||
const { setReactMenu } = renderMessages([buildMsg()], { canEdit: false })
|
||||
const bubble = screen.getByText('hello').closest('div[style="position: relative;"]') as HTMLElement
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 1, clientY: 2 }] })
|
||||
fireEvent.touchEnd(bubble, { changedTouches: [{ clientX: 1, clientY: 2 }] })
|
||||
expect(setReactMenu).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-016: an own reply quote shows the quoted author and a truncated body', () => {
|
||||
const longQuote = 'q'.repeat(120)
|
||||
renderMessages([
|
||||
buildMsg({ user_id: 1, username: 'me', reply_username: 'alice', reply_text: longQuote }),
|
||||
])
|
||||
expect(screen.getByText('alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('q'.repeat(80))).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-017: a reply without stored quote data renders empty quote fields', () => {
|
||||
renderMessages([buildMsg({ reply_to: 55, reply_text: null, reply_username: null })])
|
||||
const quote = screen.getByText('hello').closest('div[style]')!.parentElement!
|
||||
// The quote block renders before the message text with both fields blank
|
||||
expect(quote.textContent).toBe('hello')
|
||||
})
|
||||
|
||||
it('FE-W5CCM-018: a resolved link preview scrolls down when the view is pinned to the bottom', async () => {
|
||||
const { scrollToBottom } = renderMessages(
|
||||
[buildMsg({ text: 'look at https://example.com/a' })],
|
||||
{ isAtBottom: { current: true } },
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('preview-https://example.com/a'))
|
||||
await waitFor(() => expect(scrollToBottom).toHaveBeenCalledWith('smooth'))
|
||||
})
|
||||
|
||||
it('FE-W5CCM-019: a resolved link preview does not scroll when the user scrolled up', async () => {
|
||||
const { scrollToBottom } = renderMessages(
|
||||
[buildMsg({ text: 'look at https://example.com/b' })],
|
||||
{ isAtBottom: { current: false } },
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('preview-https://example.com/b'))
|
||||
await new Promise(r => setTimeout(r, 80))
|
||||
expect(scrollToBottom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-020: the reply and delete buttons react to hover and fire their handlers', () => {
|
||||
const { setReplyTo, handleDelete } = renderMessages([
|
||||
buildMsg({ id: 3, user_id: 1, username: 'me', text: 'mine' }),
|
||||
])
|
||||
const replyBtn = screen.getByTitle('collab.chat.reply')
|
||||
fireEvent.mouseEnter(replyBtn)
|
||||
expect(replyBtn.style.transform).toBe('scale(1.2)')
|
||||
fireEvent.mouseLeave(replyBtn)
|
||||
expect(replyBtn.style.transform).toBe('scale(1)')
|
||||
fireEvent.click(replyBtn)
|
||||
expect(setReplyTo).toHaveBeenCalledWith(expect.objectContaining({ id: 3 }))
|
||||
|
||||
const deleteBtn = screen.getByTitle('common.delete')
|
||||
fireEvent.mouseEnter(deleteBtn)
|
||||
expect(deleteBtn.style.background).toBe('rgb(239, 68, 68)')
|
||||
fireEvent.mouseLeave(deleteBtn)
|
||||
expect(deleteBtn.style.background).toBe('var(--accent)')
|
||||
fireEvent.click(deleteBtn)
|
||||
expect(handleDelete).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('FE-W5CCM-021: clicking a reaction badge on an own message reacts again', () => {
|
||||
const { handleReact } = renderMessages([
|
||||
buildMsg({
|
||||
id: 8,
|
||||
user_id: 1,
|
||||
username: 'me',
|
||||
reactions: [{ emoji: '🔥', count: 2, users: [{ user_id: 1, username: 'me' }] }],
|
||||
}),
|
||||
])
|
||||
fireEvent.click(screen.getByAltText('🔥').closest('button')!)
|
||||
expect(handleReact).toHaveBeenCalledWith(8, '🔥')
|
||||
})
|
||||
|
||||
it('FE-W5CCM-022: reaction badges are inert without edit rights', () => {
|
||||
const { handleReact } = renderMessages(
|
||||
[
|
||||
buildMsg({
|
||||
reactions: [{ emoji: '👍', count: 1, users: [{ user_id: 2, username: 'alice' }] }],
|
||||
}),
|
||||
],
|
||||
{ canEdit: false },
|
||||
)
|
||||
fireEvent.click(screen.getByAltText('👍').closest('button')!)
|
||||
expect(handleReact).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CCM-023: a message being deleted collapses instead of disappearing instantly', () => {
|
||||
renderMessages([buildMsg({ id: 12 })], { deletingIds: new Set([12]) })
|
||||
const row = screen.getByText('hello').closest('div[style*="row"]')!
|
||||
expect(row.getAttribute('style')).toContain('opacity: 0')
|
||||
})
|
||||
|
||||
it('FE-W5CCM-024: a single emoji message renders without a bubble background', () => {
|
||||
renderMessages([buildMsg({ text: '🎉' })])
|
||||
const big = screen.getByText('🎉')
|
||||
expect(big.getAttribute('style')).toContain('font-size: calc(40px')
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react'
|
||||
import { Trash2, Reply, ChevronUp, MessageCircle } from 'lucide-react'
|
||||
import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function ChatMessages(props: any) {
|
||||
const { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = props
|
||||
@@ -12,11 +13,7 @@ export function ChatMessages(props: any) {
|
||||
<>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32, textAlign: 'center' }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', opacity: 0.6, fontFamily: 'var(--font-subtext)' }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
<EmptyState scene="chat" title={t('collab.chat.empty')} />
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
@@ -221,12 +218,9 @@ export function ChatMessages(props: any) {
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
)
|
||||
})}
|
||||
{msg.reactions.map(r => (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => { if (canEdit) handleReact(msg.id, r.emoji) }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// FE-W4CCS-001 to FE-W4CCS-016
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { ChatReaction } from './CollabChat.types'
|
||||
import type { NoteAuthor } from './CollabNotes.types'
|
||||
|
||||
const linkPreview = vi.fn(async (_tripId: number, _url: string) => ({ title: 'TREK', image: null as string | null }))
|
||||
vi.mock('../../api/client', () => ({ collabApi: { linkPreview: (tripId: number, url: string) => linkPreview(tripId, url) } }))
|
||||
|
||||
import { TwemojiImg } from './CollabChatTwemojiImg'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import { UserAvatar } from './CollabNotesUserAvatar'
|
||||
import { WebsiteThumbnail } from './CollabNotesWebsiteThumbnail'
|
||||
|
||||
function reaction(overrides: Partial<ChatReaction> = {}): ChatReaction {
|
||||
return { emoji: '👍', count: 1, users: [{ id: 1, username: 'ada' }], ...overrides } as unknown as ChatReaction
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
linkPreview.mockReset()
|
||||
linkPreview.mockResolvedValue({ title: 'TREK', image: null })
|
||||
})
|
||||
|
||||
describe('TwemojiImg', () => {
|
||||
it('FE-W4CCS-001: renders the twemoji asset for the codepoint', () => {
|
||||
const { container } = render(<TwemojiImg emoji="👍" />)
|
||||
const img = container.querySelector('img') as HTMLImageElement
|
||||
|
||||
expect(img).toHaveAttribute('alt', '👍')
|
||||
expect(img.getAttribute('src')).toContain('/1f44d.png')
|
||||
expect(img).toHaveStyle({ width: '20px', height: '20px' })
|
||||
})
|
||||
|
||||
it('FE-W4CCS-002: strips the variation selector from a multi-codepoint emoji', () => {
|
||||
const { container } = render(<TwemojiImg emoji="❤️" size={16} />)
|
||||
|
||||
expect((container.querySelector('img') as HTMLImageElement).getAttribute('src')).toContain('/2764.png')
|
||||
})
|
||||
|
||||
it('FE-W4CCS-003: falls back to the plain glyph when the asset fails to load', () => {
|
||||
const { container } = render(<TwemojiImg emoji="👍" size={24} />)
|
||||
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(screen.getByText('👍')).toHaveStyle({ fontSize: '24px' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReactionBadge', () => {
|
||||
it('FE-W4CCS-004: hides the counter for a single reactor', () => {
|
||||
render(<ReactionBadge reaction={reaction()} currentUserId={1} onReact={() => {}} />)
|
||||
|
||||
expect(screen.queryByText('1')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-005: shows the counter once more than one person reacted', () => {
|
||||
render(
|
||||
<ReactionBadge
|
||||
reaction={reaction({ count: 3, users: [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }] } as Partial<ChatReaction>)}
|
||||
currentUserId={1}
|
||||
onReact={() => {}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-006: clicking toggles the own reaction', () => {
|
||||
const onReact = vi.fn()
|
||||
render(<ReactionBadge reaction={reaction()} currentUserId={1} onReact={onReact} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(onReact).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-007: hovering portals the list of reactors and leaving removes it', () => {
|
||||
render(
|
||||
<ReactionBadge
|
||||
reaction={reaction({ users: [{ id: 1, username: 'ada' }, { id: 2, username: 'bob' }] } as Partial<ChatReaction>)}
|
||||
currentUserId={1}
|
||||
onReact={() => {}}
|
||||
/>,
|
||||
)
|
||||
const badge = screen.getByRole('button')
|
||||
|
||||
fireEvent.mouseEnter(badge)
|
||||
expect(screen.getByText('ada, bob')).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseLeave(badge)
|
||||
expect(screen.queryByText('ada, bob')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-008: shows no tooltip when nobody is named', () => {
|
||||
render(<ReactionBadge reaction={reaction({ users: [] } as Partial<ChatReaction>)} currentUserId={1} onReact={() => {}} />)
|
||||
|
||||
fireEvent.mouseEnter(screen.getByRole('button'))
|
||||
|
||||
expect(document.body.querySelectorAll('[style*="translate(-50%, -100%)"]')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('UserAvatar', () => {
|
||||
it('FE-W4CCS-009: renders nothing without a user', () => {
|
||||
const { container } = render(<UserAvatar user={null} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-010: renders the avatar image when one is set', () => {
|
||||
const { container } = render(<UserAvatar user={{ username: 'ada', avatar: '/uploads/avatars/ada.png' } as NoteAuthor} size={20} />)
|
||||
const img = container.querySelector('img') as HTMLImageElement
|
||||
|
||||
expect(img).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||||
expect(img).toHaveAttribute('alt', 'ada')
|
||||
expect(img).toHaveStyle({ width: '20px' })
|
||||
})
|
||||
|
||||
it('FE-W4CCS-011: falls back to the first letter, and to ? without a name', () => {
|
||||
const { unmount } = render(<UserAvatar user={{ username: 'ada', avatar: null } as NoteAuthor} />)
|
||||
expect(screen.getByText('a')).toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
render(<UserAvatar user={{ username: '', avatar: null } as NoteAuthor} />)
|
||||
expect(screen.getByText('?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebsiteThumbnail', () => {
|
||||
it('FE-W4CCS-012: shows the domain until an OG image arrives', async () => {
|
||||
render(<WebsiteThumbnail url="https://www.liketrek.com/docs" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.getByText('liketrek.com')).toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'TREK'))
|
||||
expect(linkPreview).toHaveBeenCalledWith(4, 'https://www.liketrek.com/docs')
|
||||
})
|
||||
|
||||
it('FE-W4CCS-013: renders the OG image once the preview resolves', async () => {
|
||||
linkPreview.mockResolvedValue({ title: 'Docs', image: 'https://cdn.example/og.png' })
|
||||
const { container } = render(<WebsiteThumbnail url="https://example.com/a" tripId={4} color="#000" />)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/og.png')
|
||||
|
||||
// A broken OG image falls back to the domain chip.
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
expect(screen.getByText('example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4CCS-014: falls back to a link label for an unparseable url and a failing preview', async () => {
|
||||
linkPreview.mockRejectedValue(new Error('blocked'))
|
||||
render(<WebsiteThumbnail url="not a url" tripId={4} color="#000" />)
|
||||
|
||||
expect(screen.getByText('link')).toBeInTheDocument()
|
||||
await waitFor(() => expect(linkPreview).toHaveBeenCalled())
|
||||
expect(screen.getByRole('link')).toHaveAttribute('title', 'not a url')
|
||||
})
|
||||
|
||||
it('FE-W4CCS-015: caches per trip, because the preview endpoint is trip-scoped', async () => {
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 4', image: null })
|
||||
const shared = 'https://example.com/shared'
|
||||
const first = render(<WebsiteThumbnail url={shared} tripId={4} color="#000" />)
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 4'))
|
||||
first.unmount()
|
||||
|
||||
linkPreview.mockResolvedValue({ title: 'Trip 9', image: null })
|
||||
render(<WebsiteThumbnail url={shared} tripId={9} color="#000" />)
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('link')).toHaveAttribute('title', 'Trip 9'))
|
||||
expect(linkPreview).toHaveBeenCalledTimes(2)
|
||||
expect(linkPreview).toHaveBeenLastCalledWith(9, shared)
|
||||
})
|
||||
|
||||
it('FE-W4CCS-016: a broken OG image does not poison the next url', async () => {
|
||||
linkPreview.mockResolvedValue({ title: 'A', image: 'https://cdn.example/a.png' })
|
||||
const { container, rerender } = render(<WebsiteThumbnail url="https://a.example/x" tripId={4} color="#000" />)
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
|
||||
linkPreview.mockResolvedValue({ title: 'B', image: 'https://cdn.example/b.png' })
|
||||
rerender(<WebsiteThumbnail url="https://b.example/y" tripId={4} color="#000" />)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img')).not.toBeNull())
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', 'https://cdn.example/b.png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
// FE-W4CNH-001 to FE-W4CNH-008
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||||
import { formatTimestamp } from './CollabNotes.helpers'
|
||||
|
||||
// The helper feeds t() with a key and params; echo them so the branches are visible.
|
||||
const t = (key: string, params?: Record<string, number>) =>
|
||||
params ? `${key}:${Object.values(params)[0]}` : key
|
||||
|
||||
const NOW = new Date('2026-06-15T12:00:00Z')
|
||||
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(NOW)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function ago(minutes: number): string {
|
||||
return new Date(NOW.getTime() - minutes * 60_000).toISOString().replace('Z', '')
|
||||
}
|
||||
|
||||
describe('formatTimestamp', () => {
|
||||
it('FE-W4CNH-001: renders an empty string for a missing timestamp', () => {
|
||||
expect(formatTimestamp(null, t, 'en')).toBe('')
|
||||
expect(formatTimestamp('', t, 'en')).toBe('')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-002: labels the last minute as just now', () => {
|
||||
expect(formatTimestamp(ago(0), t, 'en')).toBe('collab.chat.justNow')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-003: counts minutes below the hour', () => {
|
||||
expect(formatTimestamp(ago(5), t, 'en')).toBe('collab.chat.minutesAgo:5')
|
||||
expect(formatTimestamp(ago(59), t, 'en')).toBe('collab.chat.minutesAgo:59')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-004: counts hours below the day', () => {
|
||||
expect(formatTimestamp(ago(60), t, 'en')).toBe('collab.chat.hoursAgo:1')
|
||||
expect(formatTimestamp(ago(60 * 23), t, 'en')).toBe('collab.chat.hoursAgo:23')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-005: counts days below a week', () => {
|
||||
expect(formatTimestamp(ago(60 * 24), t, 'en')).toBe('collab.notes.daysAgo:1')
|
||||
expect(formatTimestamp(ago(60 * 24 * 6), t, 'en')).toBe('collab.notes.daysAgo:6')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-006: falls back to a localized short date beyond a week', () => {
|
||||
expect(formatTimestamp(ago(60 * 24 * 10), t, 'en-US')).toBe('Jun 5')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-007: treats a naive timestamp as UTC and accepts an explicit Z', () => {
|
||||
const withZ = new Date(NOW.getTime() - 5 * 60_000).toISOString()
|
||||
expect(formatTimestamp(withZ, t, 'en')).toBe('collab.chat.minutesAgo:5')
|
||||
})
|
||||
|
||||
it('FE-W4CNH-008: falls back to English labels when the translation is missing', () => {
|
||||
const empty = () => ''
|
||||
expect(formatTimestamp(ago(0), empty, 'en')).toBe('just now')
|
||||
expect(formatTimestamp(ago(5), empty, 'en')).toBe('5m ago')
|
||||
expect(formatTimestamp(ago(120), empty, 'en')).toBe('2h ago')
|
||||
expect(formatTimestamp(ago(60 * 24 * 2), empty, 'en')).toBe('2d ago')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor, act } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, act, fireEvent, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -19,6 +19,7 @@ import { useTripStore } from '../../store/tripStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories';
|
||||
import CollabNotes from './CollabNotes';
|
||||
import { addListener } from '../../api/websocket';
|
||||
|
||||
const currentUser = buildUser({ id: 1, username: 'testuser' });
|
||||
|
||||
@@ -1268,3 +1269,554 @@ describe('CollabNotes', () => {
|
||||
expect(document.body.innerHTML.indexOf('Pinned')).toBeLessThan(document.body.innerHTML.indexOf('Unpinned'));
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5CNT-001 to FE-W5CNT-029
|
||||
// Fills in the load/error/attachment/category branches of useCollabNotes and the
|
||||
// view modal that the smoke tests above do not reach.
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>;
|
||||
|
||||
const buildNote = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
trip_id: 1,
|
||||
user_id: 1,
|
||||
author_username: 'testuser',
|
||||
author_avatar: null,
|
||||
title: 'A note',
|
||||
content: 'Body text',
|
||||
category: null,
|
||||
website: null,
|
||||
color: '#3b82f6',
|
||||
pinned: false,
|
||||
files: [],
|
||||
attachments: [],
|
||||
created_at: '2025-06-01T10:00:00.000Z',
|
||||
updated_at: '2025-06-01T10:00:00.000Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function serveNotes(payload: unknown) {
|
||||
server.use(http.get('/api/trips/1/collab/notes', () => HttpResponse.json(payload)));
|
||||
}
|
||||
|
||||
/** Serves a different payload per GET so reload-after-upload can be observed. */
|
||||
function serveNotesSequence(payloads: unknown[]) {
|
||||
let call = 0;
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/notes', () => {
|
||||
const payload = payloads[Math.min(call, payloads.length - 1)];
|
||||
call += 1;
|
||||
return HttpResponse.json(payload);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function pasteFile(name: string, type = 'image/png') {
|
||||
const file = new File(['x'], name, { type });
|
||||
fireEvent.paste(document.querySelector('form')!, {
|
||||
clipboardData: { items: [{ type, getAsFile: () => file }] },
|
||||
});
|
||||
}
|
||||
|
||||
function wsHandler(): (msg: Record<string, unknown>) => void {
|
||||
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe('CollabNotes details', () => {
|
||||
let addToast: ReturnType<typeof vi.fn<AddToast>>;
|
||||
let filesChanged: number;
|
||||
let onFilesChanged: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
addToast = vi.fn<AddToast>(() => 0);
|
||||
window.__addToast = addToast;
|
||||
filesChanged = 0;
|
||||
onFilesChanged = () => { filesChanged += 1; };
|
||||
window.addEventListener('collab-files-changed', onFilesChanged);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.removeEventListener('collab-files-changed', onFilesChanged);
|
||||
delete window.__addToast;
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-001: a corrupt category cache in localStorage is ignored', async () => {
|
||||
localStorage.setItem('collab-cats-1', '{not json');
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-002: a category without a stored colour falls back to the first palette entry', async () => {
|
||||
serveNotes({ notes: [buildNote({ category: 'Ideas', color: null })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('A note');
|
||||
// The card chip is a span, the filter pill above the grid is a button
|
||||
const chip = screen.getAllByText('Ideas').find(el => el.tagName === 'SPAN')!;
|
||||
expect(chip.style.color).toBe('rgb(99, 102, 241)');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-003: without a trip id nothing is fetched and the panel stays in its loading state', () => {
|
||||
render(<CollabNotes tripId={0} currentUser={currentUser} />);
|
||||
expect(screen.getByRole('heading', { name: 'Notes' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('New Note')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-004: notes served as a bare array are rendered', async () => {
|
||||
serveNotes([buildNote({ title: 'Array note' })]);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Array note');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-005: an empty payload yields an empty list', async () => {
|
||||
serveNotes(null);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-006: a failing load falls back to the empty state', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-007: a WebSocket create for a note already in the list does not duplicate it', async () => {
|
||||
serveNotes({ notes: [buildNote({ id: 4, title: 'Already here' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Already here');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:note:created', note: buildNote({ id: 4, title: 'Already here' }) });
|
||||
});
|
||||
expect(screen.getAllByText('Already here')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-008: a WebSocket update only touches the matching note', async () => {
|
||||
serveNotes({
|
||||
notes: [buildNote({ id: 1, title: 'First' }), buildNote({ id: 2, title: 'Second' })],
|
||||
});
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Second');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:note:updated', note: { id: 2, title: 'Second renamed' } });
|
||||
});
|
||||
expect(await screen.findByText('Second renamed')).toBeInTheDocument();
|
||||
expect(screen.getByText('First')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-009: a WebSocket delete accepts a plain id and ignores events without one', async () => {
|
||||
serveNotes({ notes: [buildNote({ id: 9, title: 'Doomed' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Doomed');
|
||||
const handler = wsHandler();
|
||||
await act(async () => { handler({ type: 'collab:note:deleted' }); });
|
||||
expect(screen.getByText('Doomed')).toBeInTheDocument();
|
||||
await act(async () => { handler({ type: 'collab:note:deleted', id: 9 }); });
|
||||
await waitFor(() => expect(screen.queryByText('Doomed')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-W5CNT-010: an unwrapped create response is prepended to the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () =>
|
||||
HttpResponse.json(buildNote({ id: 20, title: 'Fresh note' })),
|
||||
),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await screen.findByText('Fresh note');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-011: an empty create response leaves the list untouched', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(http.post('/api/trips/1/collab/notes', () => HttpResponse.json(null)));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Ghost note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('No notes yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-012: a failing create reports an error and keeps the modal open', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Doomed note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-013: a pasted attachment is uploaded and the list is reloaded afterwards', async () => {
|
||||
const user = userEvent.setup();
|
||||
let uploaded = 0;
|
||||
serveNotesSequence([
|
||||
{ notes: [] },
|
||||
{ notes: [buildNote({ id: 30, title: 'With file' })] },
|
||||
]);
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 30, title: 'With file' }) }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/notes/30/files', () => {
|
||||
uploaded += 1;
|
||||
return HttpResponse.json({ file: { id: 1 } });
|
||||
}),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'With file');
|
||||
pasteFile('screenshot.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await screen.findByText('With file');
|
||||
expect(uploaded).toBe(1);
|
||||
expect(filesChanged).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-014: a failing upload reports an error and the array-shaped reload is ignored', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotesSequence([{ notes: [] }, [buildNote({ id: 31, title: 'Never shown' })]]);
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 31, title: 'Upload fails' }) }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/notes/31/files', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('No notes yet');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Upload fails');
|
||||
pasteFile('broken.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
await waitFor(() => expect(filesChanged).toBe(1));
|
||||
expect(screen.queryByText('Never shown')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-015: pinning a note applies the unwrapped response to that note only', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({
|
||||
notes: [buildNote({ id: 1, title: 'Pin me' }), buildNote({ id: 2, title: 'Leave me' })],
|
||||
});
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/1', () =>
|
||||
HttpResponse.json(buildNote({ id: 1, title: 'Pinned now', pinned: true })),
|
||||
),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Pin me');
|
||||
const pinBtn = screen.getAllByTitle('Pin')[0];
|
||||
await user.click(pinBtn);
|
||||
await screen.findByText('Pinned now');
|
||||
expect(screen.getByText('Leave me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-016: an empty update response leaves the note as it was', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 1, title: 'Unchanged' })] });
|
||||
server.use(http.put('/api/trips/1/collab/notes/1', () => HttpResponse.json(null)));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Unchanged');
|
||||
await user.click(screen.getByTitle('Pin'));
|
||||
await waitFor(() => expect(screen.getByText('Unchanged')).toBeInTheDocument());
|
||||
expect(screen.getByTitle('Pin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-017: a failing edit reports an error and keeps the edit modal open', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 3, title: 'Edit me' })] });
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/3', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
const titleInput = await screen.findByDisplayValue('Edit me');
|
||||
await user.type(titleInput, ' v2');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByDisplayValue('Edit me v2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-018: saving a new category colour rewrites every note in that category', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
serveNotes({ notes: [buildNote({ id: 1, title: 'Sushi', category: 'Food', color: '#ef4444' })] });
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/1', async ({ request }) => {
|
||||
bodies.push((await request.json()) as Record<string, unknown>);
|
||||
return HttpResponse.json({ note: buildNote({ id: 1, title: 'Sushi', category: 'Food', color: '#10b981' }) });
|
||||
}),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Sushi');
|
||||
await user.click(screen.getByTitle('Manage Categories'));
|
||||
const label = (await screen.findAllByText('Food')).find(el => el.title === 'Click to rename')!;
|
||||
const swatches = label.parentElement!.querySelectorAll('button');
|
||||
await user.click(swatches[3]);
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(bodies).toEqual([{ color: '#10b981' }]));
|
||||
});
|
||||
|
||||
it('FE-W5CNT-019: attaching a file while editing uploads it and refreshes the note', async () => {
|
||||
const user = userEvent.setup();
|
||||
let uploaded = 0;
|
||||
serveNotesSequence([
|
||||
{ notes: [buildNote({ id: 3, title: 'Edit me' })] },
|
||||
{ notes: [buildNote({ id: 3, title: 'Edited', attachments: [] })] },
|
||||
]);
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/3', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 3, title: 'Edited' }) }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/notes/3/files', () => {
|
||||
uploaded += 1;
|
||||
return HttpResponse.json({ file: { id: 2 } });
|
||||
}),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await screen.findByDisplayValue('Edit me');
|
||||
pasteFile('attachment.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await screen.findByText('Edited');
|
||||
expect(uploaded).toBe(1);
|
||||
expect(filesChanged).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-020: a failing upload during an edit reports an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotesSequence([
|
||||
{ notes: [buildNote({ id: 3, title: 'Edit me' })] },
|
||||
[buildNote({ id: 3, title: 'Ignored reload' })],
|
||||
]);
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/notes/3', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 3, title: 'Edit me' }) }),
|
||||
),
|
||||
http.post('/api/trips/1/collab/notes/3/files', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Edit me');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
await screen.findByDisplayValue('Edit me');
|
||||
pasteFile('nope.png');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.queryByText('Ignored reload')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-021: a failing attachment removal reports an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({
|
||||
notes: [buildNote({
|
||||
id: 3,
|
||||
title: 'Has file',
|
||||
attachments: [{ id: 9, filename: 's.pdf', original_name: 'plan.pdf', mime_type: 'application/pdf', url: '/uploads/plan.pdf' }],
|
||||
})],
|
||||
});
|
||||
server.use(
|
||||
http.delete('/api/trips/1/collab/notes/3/files/9', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Has file');
|
||||
await user.click(screen.getByTitle('Edit'));
|
||||
const chip = (await screen.findByText('plan.pdf')).closest('div')!;
|
||||
await user.click(chip.querySelector('button')!);
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(filesChanged).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-022: pinned notes sort first and notes without timestamps sort last', async () => {
|
||||
serveNotes({
|
||||
notes: [
|
||||
buildNote({ id: 1, title: 'No timestamps', updated_at: null, created_at: null }),
|
||||
buildNote({ id: 2, title: 'Pinned one', pinned: true }),
|
||||
buildNote({ id: 3, title: 'Created only', updated_at: null, created_at: '2025-06-02T10:00:00.000Z' }),
|
||||
buildNote({ id: 4, title: 'Also undated', updated_at: null, created_at: null }),
|
||||
],
|
||||
});
|
||||
const known = ['No timestamps', 'Pinned one', 'Created only', 'Also undated'];
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Pinned one');
|
||||
const titles = Array.from(document.querySelectorAll('span'))
|
||||
.filter(el => el.childElementCount === 0)
|
||||
.map(el => el.textContent)
|
||||
.filter(text => known.includes(text ?? ''));
|
||||
expect(titles).toEqual(['Pinned one', 'Created only', 'No timestamps', 'Also undated']);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-023: clicking the active category pill clears the filter again', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({
|
||||
notes: [
|
||||
buildNote({ id: 1, title: 'Food note', category: 'Food', color: '#ef4444' }),
|
||||
buildNote({ id: 2, title: 'Plain note' }),
|
||||
],
|
||||
});
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Plain note');
|
||||
const pill = screen.getAllByRole('button').find(b => b.textContent === 'Food')!;
|
||||
await user.click(pill);
|
||||
await waitFor(() => expect(screen.queryByText('Plain note')).not.toBeInTheDocument());
|
||||
await user.click(pill);
|
||||
expect(await screen.findByText('Plain note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-024: a narrow viewport lays the grid out in a single column', async () => {
|
||||
const original = window.innerWidth;
|
||||
Object.defineProperty(window, 'innerWidth', { value: 500, writable: true, configurable: true });
|
||||
try {
|
||||
serveNotes({ notes: [buildNote({ title: 'Mobile note' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Mobile note');
|
||||
const grid = document.querySelector('[style*="grid-template-columns"]') as HTMLElement;
|
||||
expect(grid.style.gridTemplateColumns).toBe('1fr');
|
||||
} finally {
|
||||
Object.defineProperty(window, 'innerWidth', { value: original, writable: true, configurable: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-W5CNT-025: the expanded note closes on a backdrop click and its buttons highlight on hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 5, title: 'Long note', content: 'Full body', category: 'Food', color: '#ef4444' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Long note');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const modal = await waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full')
|
||||
if (!md) throw new Error('view modal not open yet')
|
||||
return md.closest('div[style*="position: fixed"]') as HTMLElement
|
||||
});
|
||||
expect(within(modal).getByText('Full body')).toBeInTheDocument();
|
||||
|
||||
const [editBtn, closeBtn] = Array.from(modal.querySelectorAll('button'));
|
||||
fireEvent.mouseEnter(editBtn);
|
||||
expect(editBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(editBtn);
|
||||
expect(editBtn.style.color).toBe('var(--text-faint)');
|
||||
fireEvent.mouseEnter(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-faint)');
|
||||
|
||||
fireEvent.click(modal);
|
||||
await waitFor(() => expect(document.querySelector('.collab-note-md-full')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-W5CNT-026: attachments in the expanded note open the preview and react to hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({
|
||||
notes: [buildNote({
|
||||
id: 6,
|
||||
title: 'Trip docs',
|
||||
content: 'See attachments',
|
||||
attachments: [
|
||||
{ id: 1, filename: 'a.png', original_name: 'map.png', mime_type: 'image/png', url: '/uploads/map.png' },
|
||||
{ id: 2, filename: 'b.zip', original_name: 'itinerary.zip', mime_type: 'application/zip', url: '/uploads/itinerary.zip' },
|
||||
{ id: 3, filename: 'c', url: '/uploads/c' },
|
||||
],
|
||||
})],
|
||||
});
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Trip docs');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const modal = await waitFor(() => {
|
||||
const md = document.querySelector('.collab-note-md-full')
|
||||
if (!md) throw new Error('view modal not open yet')
|
||||
return md.closest('div[style*="position: fixed"]') as HTMLElement
|
||||
});
|
||||
|
||||
// Unknown mime type and missing name fall back to a "?" tile
|
||||
expect(within(modal).getByText('?')).toBeInTheDocument();
|
||||
|
||||
const zipTile = within(modal).getByTitle('itinerary.zip');
|
||||
expect(zipTile.style.background).toBe('var(--bg-secondary)');
|
||||
expect(within(modal).getByText('ZIP')).toBeInTheDocument();
|
||||
fireEvent.mouseEnter(zipTile);
|
||||
expect(zipTile.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(zipTile);
|
||||
expect(zipTile.style.transform).toBe('scale(1)');
|
||||
fireEvent.click(zipTile);
|
||||
// FilePreviewPortal shows a download action for non-image files
|
||||
expect(await screen.findByText('Download itinerary.zip')).toBeInTheDocument();
|
||||
|
||||
const image = await waitFor(() => {
|
||||
const img = modal.querySelector('img[alt="map.png"]') as HTMLImageElement | null;
|
||||
if (!img) throw new Error('image attachment not rendered yet');
|
||||
return img;
|
||||
});
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(image.style.transform).toBe('scale(1.06)');
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(image.style.transform).toBe('scale(1)');
|
||||
fireEvent.click(image);
|
||||
await waitFor(() => expect(screen.queryByText('Download itinerary.zip')).not.toBeInTheDocument());
|
||||
});
|
||||
it('FE-W5CNT-027: a create response for a note already in the list is not added twice', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 20, title: 'Fresh note' })] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 20, title: 'Fresh note' }) }),
|
||||
),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Fresh note');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Fresh note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await waitFor(() => expect(screen.queryByPlaceholderText('Note title')).not.toBeInTheDocument());
|
||||
expect(screen.getAllByText('Fresh note')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CNT-028: a second note created elsewhere is prepended to the existing list', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 20, title: 'Older note' })] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/notes', () =>
|
||||
HttpResponse.json({ note: buildNote({ id: 21, title: 'Newer note' }) }),
|
||||
),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Older note');
|
||||
await user.click(screen.getByText('New Note'));
|
||||
await user.type(await screen.findByPlaceholderText('Note title'), 'Newer note');
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
await screen.findByText('Newer note');
|
||||
expect(screen.getByText('Older note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CNT-029: a failing delete reports an error and keeps the note in the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 30, title: 'Stubborn note' })] });
|
||||
server.use(
|
||||
http.delete('/api/trips/1/collab/notes/30', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Stubborn note');
|
||||
|
||||
await user.click(screen.getByTitle('Delete'));
|
||||
const dialog = (await screen.findByText('Delete note?')).closest('div.trek-modal-enter') as HTMLElement;
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Stubborn note')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { User } from '../../types'
|
||||
import type { CollabNote } from './CollabNotes.types'
|
||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||
@@ -270,7 +271,7 @@ function CollabNotesHeader({ t, canEdit, setShowSettings, setShowNewModal }: Not
|
||||
{t('collab.notes.title')}
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings') || 'Categories'}
|
||||
{canEdit && <button onClick={() => setShowSettings(true)} title={t('collab.notes.categorySettings')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 8, border: 'none', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', transition: 'color 0.12s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
@@ -329,18 +330,7 @@ function CollabNotesGrid(S: NotesState) {
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||
{sortedNotes.length === 0 ? (
|
||||
/* ── Empty state ── */
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
|
||||
padding: '48px 20px', textAlign: 'center', height: '100%',
|
||||
}}>
|
||||
<Pencil size={36} color="var(--text-faint)" style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4, fontFamily: FONT }}>
|
||||
{t('collab.notes.empty')}
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', fontFamily: FONT }}>
|
||||
{t('collab.notes.emptyDesc') || 'Create a note to get started'}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState scene="notes" title={t('collab.notes.empty')} />
|
||||
) : (
|
||||
/* ── Notes grid — 2 columns ── */
|
||||
<div style={{
|
||||
@@ -536,7 +526,8 @@ export default function CollabNotes(props: CollabNotesProps) {
|
||||
<ConfirmDialog
|
||||
isOpen={pendingDeleteNoteId !== null}
|
||||
onClose={() => setPendingDeleteNoteId(null)}
|
||||
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
|
||||
// Hand the promise back so the dialog absorbs the rethrow of a failed DELETE.
|
||||
onConfirm={() => (pendingDeleteNoteId !== null ? handleDeleteNote(pendingDeleteNoteId) : undefined)}
|
||||
title={t('collab.notes.confirmDeleteTitle')}
|
||||
message={t('collab.notes.confirmDeleteBody')}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// FE-W5CNF-001 to FE-W5CNF-021
|
||||
// NoteFormModal takes everything it needs as props, so the tests drive it
|
||||
// directly rather than through CollabNotes.
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { usePermissionsStore } from '../../store/permissionsStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser, buildTrip } from '../../../tests/helpers/factories'
|
||||
import { NoteFormModal } from './CollabNotesFormModal'
|
||||
import type { CollabNote, NoteFile } from './CollabNotes.types'
|
||||
|
||||
const identity = (key: string) => key
|
||||
|
||||
function buildAttachment(overrides: Partial<NoteFile> = {}): NoteFile {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.png',
|
||||
original_name: 'photo.png',
|
||||
mime_type: 'image/png',
|
||||
url: '/uploads/collab/photo.png',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function buildNote(overrides: Partial<CollabNote> = {}): CollabNote {
|
||||
return {
|
||||
id: 42,
|
||||
trip_id: 1,
|
||||
title: 'Existing note',
|
||||
content: 'Existing content',
|
||||
category: 'Food',
|
||||
website: 'https://example.com',
|
||||
pinned: false,
|
||||
color: '#6366f1',
|
||||
username: 'tester',
|
||||
avatar_url: null,
|
||||
avatar: null,
|
||||
user_id: 1,
|
||||
created_at: '2025-06-01T10:00:00.000Z',
|
||||
attachments: [],
|
||||
...overrides,
|
||||
} as CollabNote
|
||||
}
|
||||
|
||||
interface ModalOverrides {
|
||||
note?: CollabNote | null
|
||||
onClose?: () => void
|
||||
onSubmit?: (data: Record<string, unknown>) => Promise<void>
|
||||
onDeleteFile?: (noteId: number, fileId: number) => Promise<void>
|
||||
existingCategories?: string[]
|
||||
categoryColors?: Record<string, string>
|
||||
t?: (key: string) => string
|
||||
}
|
||||
|
||||
function renderModal(overrides: ModalOverrides = {}) {
|
||||
const onSubmit = overrides.onSubmit ?? vi.fn(async () => {})
|
||||
const onClose = overrides.onClose ?? vi.fn(() => {})
|
||||
render(
|
||||
<NoteFormModal
|
||||
note={overrides.note ?? null}
|
||||
tripId={1}
|
||||
onClose={onClose}
|
||||
onSubmit={onSubmit as unknown as React.ComponentProps<typeof NoteFormModal>['onSubmit']}
|
||||
onDeleteFile={overrides.onDeleteFile}
|
||||
existingCategories={overrides.existingCategories ?? []}
|
||||
categoryColors={overrides.categoryColors as Record<string, string>}
|
||||
getCategoryColor={(cat: string) => (cat === 'Food' ? '#ef4444' : '#6366f1')}
|
||||
t={overrides.t ?? identity}
|
||||
/>,
|
||||
)
|
||||
return { onSubmit, onClose }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1 }), isAuthenticated: true })
|
||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, user_id: 1 }) })
|
||||
})
|
||||
|
||||
describe('NoteFormModal', () => {
|
||||
it('FE-W5CNF-001: falls back to an empty color map when none is supplied', () => {
|
||||
renderModal({ existingCategories: ['Food'] })
|
||||
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-002: submitting with a blank title does nothing', async () => {
|
||||
const { onSubmit, onClose } = renderModal()
|
||||
const form = document.querySelector('form')!
|
||||
fireEvent.submit(form)
|
||||
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled())
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-003: a filled-in note is submitted trimmed and the modal closes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit, onClose } = renderModal({ existingCategories: ['Food'] })
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), ' Dinner ')
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.contentPlaceholder'), 'Book a table')
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.websitePlaceholder'), ' https://trek.test ')
|
||||
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
title: 'Dinner',
|
||||
content: 'Book a table',
|
||||
category: 'Food',
|
||||
color: '#ef4444',
|
||||
website: 'https://trek.test',
|
||||
_pendingFiles: [],
|
||||
})
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-004: a note without a category submits null instead of an empty string', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderModal()
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Loose note')
|
||||
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ category: null, website: null }))
|
||||
})
|
||||
|
||||
it('FE-W5CNF-005: a rejected submit keeps the modal open and re-enables the button', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSubmit = vi.fn(async () => { throw new Error('boom') })
|
||||
const { onClose } = renderModal({ onSubmit })
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Fails')
|
||||
const submit = screen.getByRole('button', { name: 'collab.notes.create' })
|
||||
await user.click(submit)
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
expect(submit).toBeEnabled()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-006: edit mode prefills the fields and uses the save label', () => {
|
||||
renderModal({ note: buildNote() })
|
||||
expect(screen.getByDisplayValue('Existing note')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Existing content')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'collab.notes.save' })).toBeInTheDocument()
|
||||
expect(screen.getByText('collab.notes.edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-007: the close button calls back without submitting', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onClose, onSubmit } = renderModal()
|
||||
const header = screen.getByText('collab.notes.new').parentElement!
|
||||
await user.click(header.querySelector('button')!)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-008: picking another category marks it active and submits it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSubmit } = renderModal({
|
||||
existingCategories: ['Food'],
|
||||
categoryColors: { Sights: '#6366f1' },
|
||||
})
|
||||
const sights = screen.getByRole('button', { name: 'Sights' })
|
||||
expect(sights.style.background).toBe('transparent')
|
||||
await user.click(sights)
|
||||
expect(sights.style.background).toBe('rgba(99, 102, 241, 0.094)')
|
||||
await user.type(screen.getByPlaceholderText('collab.notes.titlePlaceholder'), 'Museum')
|
||||
await user.click(screen.getByRole('button', { name: 'collab.notes.create' }))
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
||||
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ category: 'Sights' }))
|
||||
})
|
||||
|
||||
it('FE-W5CNF-009: pasting an image attaches it as a pending file', () => {
|
||||
renderModal()
|
||||
const file = new File(['x'], 'pasted.png', { type: 'image/png' })
|
||||
fireEvent.paste(document.querySelector('form')!, {
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
|
||||
})
|
||||
expect(screen.getByText('pasted.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-010: pasting a PDF after a non-file item attaches the PDF', () => {
|
||||
renderModal()
|
||||
const file = new File(['%PDF'], 'itinerary.pdf', { type: 'application/pdf' })
|
||||
fireEvent.paste(document.querySelector('form')!, {
|
||||
clipboardData: {
|
||||
items: [
|
||||
{ type: 'text/plain', getAsFile: () => null },
|
||||
{ type: 'application/pdf', getAsFile: () => file },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('itinerary.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-011: a paste whose item yields no file attaches nothing', () => {
|
||||
renderModal()
|
||||
fireEvent.paste(document.querySelector('form')!, {
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: () => null }] },
|
||||
})
|
||||
expect(screen.queryByText(/\.png$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-012: a paste without clipboard items is ignored', () => {
|
||||
renderModal()
|
||||
fireEvent.paste(document.querySelector('form')!, { clipboardData: {} })
|
||||
expect(screen.getByPlaceholderText('collab.notes.titlePlaceholder')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-013: without upload rights the file section is hidden and paste is ignored', () => {
|
||||
seedStore(usePermissionsStore, { permissions: { file_upload: 'admin' } })
|
||||
renderModal()
|
||||
expect(screen.queryByText('collab.notes.attachFiles')).not.toBeInTheDocument()
|
||||
const file = new File(['x'], 'blocked.png', { type: 'image/png' })
|
||||
fireEvent.paste(document.querySelector('form')!, {
|
||||
clipboardData: { items: [{ type: 'image/png', getAsFile: () => file }] },
|
||||
})
|
||||
expect(screen.queryByText('blocked.png')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-014: choosing files through the picker lists them and long names are truncated', () => {
|
||||
renderModal()
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const longName = 'an-extremely-long-attachment-name.pdf'
|
||||
fireEvent.change(input, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['a'], 'short.png', { type: 'image/png' }),
|
||||
new File(['b'], longName, { type: 'application/pdf' }),
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(screen.getByText('short.png')).toBeInTheDocument()
|
||||
expect(screen.getByText(`${longName.slice(0, 17)}...`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-015: an empty file selection changes nothing', () => {
|
||||
renderModal()
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [] } })
|
||||
expect(screen.queryByText(/\.png$/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-016: a pending file can be removed again', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(input, { target: { files: [new File(['a'], 'remove-me.png', { type: 'image/png' })] } })
|
||||
const chip = screen.getByText('remove-me.png').closest('div')!
|
||||
await user.click(chip.querySelector('button')!)
|
||||
expect(screen.queryByText('remove-me.png')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-017: the add button opens the hidden file picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal()
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const clickSpy = vi.spyOn(input, 'click').mockImplementation(() => {})
|
||||
await user.click(screen.getByRole('button', { name: 'files.attach' }))
|
||||
expect(clickSpy).toHaveBeenCalled()
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-018: the add button falls back to an English label', () => {
|
||||
renderModal({ t: (key: string) => (key === 'files.attach' ? '' : key) })
|
||||
expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-019: deleting an existing attachment calls back and drops the chip', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDeleteFile = vi.fn(async () => {})
|
||||
renderModal({
|
||||
note: buildNote({
|
||||
attachments: [
|
||||
buildAttachment({ id: 7, original_name: 'a-really-long-attachment-name.pdf', mime_type: 'application/pdf' }),
|
||||
],
|
||||
}),
|
||||
onDeleteFile,
|
||||
})
|
||||
const chip = screen.getByText('a-really-long-att...').closest('div')!
|
||||
await user.click(chip.querySelector('button')!)
|
||||
await waitFor(() => expect(onDeleteFile).toHaveBeenCalledWith(42, 7))
|
||||
expect(screen.queryByText('a-really-long-att...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-020: an attachment without a delete handler stays in the list', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderModal({ note: buildNote({ attachments: [buildAttachment({ id: 9 })] }) })
|
||||
const chip = screen.getByText('photo.png').closest('div')!
|
||||
await user.click(chip.querySelector('button')!)
|
||||
expect(screen.getByText('photo.png')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CNF-021: an attachment without an original name renders an empty label', () => {
|
||||
renderModal({
|
||||
note: buildNote({
|
||||
attachments: [buildAttachment({ id: 11, original_name: undefined as unknown as string, mime_type: 'text/plain' })],
|
||||
}),
|
||||
})
|
||||
const section = screen.getByText('collab.notes.attachFiles').parentElement!
|
||||
expect(section.querySelectorAll('img').length).toBe(0)
|
||||
})
|
||||
})
|
||||
Binary file not shown.
@@ -143,3 +143,125 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
originalInnerWidth = window.innerWidth
|
||||
resetAllStores()
|
||||
seedStore(useAuthStore, { user: buildUser() })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: originalInnerWidth, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
it('FE-W5CPN-001: renders nothing when every collab feature is disabled', () => {
|
||||
setViewport(1280)
|
||||
const { container } = render(<CollabPanel tripId={1} collabFeatures={allOff} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-002: chat alone fills the whole desktop panel', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-003: chat plus notes puts the notes card next to the chat column', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, notes: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-004: chat plus polls renders the polls card as the only right panel', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("FE-W5CPN-005: chat plus what's next renders the widget as the only right panel", () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-006: chat plus notes and polls stacks both right panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, notes: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("FE-W5CPN-007: chat plus polls and what's next stacks those two", () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-008: notes alone fills the panel when chat is off', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-009: polls alone fills the panel when chat is off', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("FE-W5CPN-010: what's next alone fills the panel when chat is off", () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-011: notes and polls share the width side by side when chat is off', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("FE-W5CPN-012: polls and what's next share the width when chat is off", () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /polls/i }))
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
|
||||
rerender(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /polls/i })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,10 +39,10 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -55,7 +55,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
label: t(tab.labelKey),
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock('../../api/websocket', () => ({
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import { render, screen, waitFor, fireEvent, act } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
@@ -30,7 +30,7 @@ const buildPoll = (overrides: Record<string, unknown> = {}) => ({
|
||||
{ id: 1, text: 'Paris', label: 'Paris', voters: [] },
|
||||
{ id: 2, text: 'Rome', label: 'Rome', voters: [] },
|
||||
],
|
||||
multi_choice: false,
|
||||
multiple_choice: false,
|
||||
is_closed: false,
|
||||
deadline: null,
|
||||
created_by: 1,
|
||||
@@ -273,3 +273,432 @@ describe('CollabPolls', () => {
|
||||
expect(optionInputs).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5CPL-001 to FE-W5CPL-028
|
||||
// Covers the deadline maths, the voter chips, the error paths of every mutation
|
||||
// and the WebSocket handler branches that the smoke tests above skip.
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>;
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/** A deadline `ms` in the future, with a small buffer so the clock can tick. */
|
||||
const inFuture = (ms: number) => new Date(Date.now() + ms + 10_000).toISOString();
|
||||
|
||||
function servePolls(polls: unknown) {
|
||||
server.use(http.get('/api/trips/1/collab/polls', () => HttpResponse.json(polls)));
|
||||
}
|
||||
|
||||
/** Grabs the WS handler CollabPolls registered on mount. */
|
||||
function wsHandler(): (msg: Record<string, unknown>) => void {
|
||||
return (addListener as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
}
|
||||
|
||||
describe('CollabPolls details', () => {
|
||||
let addToast: ReturnType<typeof vi.fn<AddToast>>;
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<AddToast>(() => 0);
|
||||
window.__addToast = addToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-W5CPL-001: a deadline more than a day away is shown in days and hours', async () => {
|
||||
servePolls({ polls: [buildPoll({ deadline: inFuture(2 * DAY + 3 * HOUR) })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('2d 3h')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-002: a deadline within the day is shown in hours and minutes', async () => {
|
||||
servePolls({ polls: [buildPoll({ deadline: inFuture(5 * HOUR + 30 * MINUTE) })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('5h 30m')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-003: a deadline within the hour is shown in minutes', async () => {
|
||||
servePolls({ polls: [buildPoll({ deadline: inFuture(45 * MINUTE) })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
expect(await screen.findByText('45m')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-004: a passed deadline closes the poll and drops the countdown', async () => {
|
||||
servePolls({ polls: [buildPoll({ deadline: '2020-01-01T00:00:00.000Z' })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
expect(screen.getByText(/closed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Paris').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-005: a poll served as a bare array is rendered', async () => {
|
||||
servePolls([buildPoll({ question: 'Array shaped?' })]);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Array shaped?');
|
||||
});
|
||||
|
||||
it('FE-W5CPL-006: options without a voters array count as zero votes', async () => {
|
||||
servePolls({ polls: [{ ...buildPoll(), options: [{ id: 1, text: 'Solo' }] }] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Solo');
|
||||
expect(screen.getByText('0 votes')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-007: a poll without an options array still renders its question', async () => {
|
||||
servePolls({ polls: [{ ...buildPoll(), options: undefined }] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
expect(screen.queryByText('Paris')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-008: plain string options are rendered as their own label', async () => {
|
||||
servePolls({ polls: [{ ...buildPoll(), options: ['Yes', 'No'] }] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Yes');
|
||||
expect(screen.getByText('No')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-009: a multiple-choice poll shows the multi badge and a single vote label', async () => {
|
||||
servePolls({
|
||||
polls: [buildPoll({
|
||||
multiple_choice: true,
|
||||
options: [
|
||||
{ id: 1, text: 'Paris', voters: [{ user_id: 9, username: 'bob', avatar_url: null }] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
expect(screen.getByText('Multiple choice')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 vote')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-010: once the user voted the results, avatars and tooltip appear', async () => {
|
||||
servePolls({
|
||||
polls: [buildPoll({
|
||||
options: [
|
||||
{
|
||||
id: 1, text: 'Paris',
|
||||
voters: [
|
||||
{ user_id: 1, username: 'testuser', avatar_url: null },
|
||||
{ user_id: 2, username: 'alice', avatar_url: '/uploads/avatars/alice.png' },
|
||||
],
|
||||
},
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
expect(screen.getByText('0%')).toBeInTheDocument();
|
||||
expect(document.querySelector('img[src="/uploads/avatars/alice.png"]')).toBeInTheDocument();
|
||||
|
||||
const chip = screen.getByText('T');
|
||||
fireEvent.mouseEnter(chip);
|
||||
expect(await screen.findByText('testuser')).toBeInTheDocument();
|
||||
fireEvent.mouseLeave(chip);
|
||||
await waitFor(() => expect(screen.queryByText('testuser')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-W5CPL-011: a voter without a username falls back to a question mark', async () => {
|
||||
servePolls({
|
||||
polls: [buildPoll({
|
||||
is_closed: true,
|
||||
options: [
|
||||
{ id: 1, text: 'Paris', voters: [{ user_id: null, username: '', avatar_url: null }] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})],
|
||||
});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
expect(screen.getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-012: hovering an open option scales it, a closed one stays put', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 1 }), buildPoll({ id: 2, question: 'Done?', is_closed: true })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Done?');
|
||||
const [openOption, closedOption] = screen.getAllByText('Paris').map(el => el.closest('button')!);
|
||||
fireEvent.mouseEnter(openOption);
|
||||
expect(openOption.style.transform).toBe('scale(1.01)');
|
||||
fireEvent.mouseLeave(openOption);
|
||||
expect(openOption.style.transform).toBe('scale(1)');
|
||||
|
||||
fireEvent.mouseEnter(closedOption);
|
||||
expect(closedOption.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('FE-W5CPL-013: the closed section heading only appears next to active polls', async () => {
|
||||
servePolls({
|
||||
polls: [
|
||||
buildPoll({ id: 1, question: 'Still open?' }),
|
||||
buildPoll({ id: 2, question: 'Already done?', is_closed: true }),
|
||||
],
|
||||
});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Still open?');
|
||||
expect(screen.getByText('Already done?')).toBeInTheDocument();
|
||||
// One "Closed" badge on the poll itself plus the section heading above it
|
||||
expect(screen.getAllByText('Closed')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-014: closing a poll marks it closed and leaves the other one open', async () => {
|
||||
let closeCalled = false;
|
||||
servePolls({ polls: [buildPoll({ id: 5 }), buildPoll({ id: 6, question: 'Stays open?' })] });
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/polls/5/close', () => {
|
||||
closeCalled = true;
|
||||
return HttpResponse.json({ success: true });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Stays open?');
|
||||
|
||||
// Both action buttons highlight on hover
|
||||
const closeBtn = screen.getAllByTitle('Close')[0];
|
||||
fireEvent.mouseEnter(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-primary)');
|
||||
fireEvent.mouseLeave(closeBtn);
|
||||
expect(closeBtn.style.color).toBe('var(--text-faint)');
|
||||
const deleteBtn = screen.getAllByTitle('Delete')[0];
|
||||
fireEvent.mouseEnter(deleteBtn);
|
||||
expect(deleteBtn.style.color).toBe('rgb(239, 68, 68)');
|
||||
fireEvent.mouseLeave(deleteBtn);
|
||||
expect(deleteBtn.style.color).toBe('var(--text-faint)');
|
||||
|
||||
await user.click(closeBtn);
|
||||
await waitFor(() => expect(closeCalled).toBe(true));
|
||||
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
||||
expect(screen.getAllByTitle('Close')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-027: a failing poll request falls back to the empty state', async () => {
|
||||
server.use(
|
||||
http.get('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-028: a payload without a polls key yields an empty list', async () => {
|
||||
servePolls({});
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-015: a failing close shows an error and leaves the poll open', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 5 })] });
|
||||
server.use(
|
||||
http.put('/api/trips/1/collab/polls/5/close', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
await user.click(screen.getByTitle(/close/i));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByTitle(/close/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-016: a failing delete shows an error and keeps the poll', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 6 })] });
|
||||
server.use(
|
||||
http.delete('/api/trips/1/collab/polls/6', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
await user.click(screen.getByTitle(/delete/i));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByText('Best destination?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-017: a failing vote shows an error and leaves the tally alone', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 7 })] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls/7/vote', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Paris');
|
||||
await user.click(screen.getByText('Paris'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.queryByText('100%')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-018: an unwrapped vote response only replaces the poll that was voted on', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 7 }), buildPoll({ id: 8, question: 'Untouched?' })] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls/7/vote', () =>
|
||||
HttpResponse.json(buildPoll({
|
||||
id: 7,
|
||||
question: 'Voted!',
|
||||
options: [
|
||||
{ id: 1, text: 'Paris', voters: [{ user_id: 1, username: 'testuser', avatar_url: null }] },
|
||||
{ id: 2, text: 'Rome', voters: [] },
|
||||
],
|
||||
})),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Untouched?');
|
||||
await user.click(screen.getAllByText('Paris')[0]);
|
||||
await screen.findByText('Voted!');
|
||||
expect(screen.getByText('Untouched?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-019: a failing create shows an error and keeps the modal open', async () => {
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', () => new HttpResponse(null, { status: 500 })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Fails?');
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined));
|
||||
expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-W5CPL-020: creating a poll that is already in the list does not duplicate it', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 12, question: 'Same poll' })] });
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', () =>
|
||||
HttpResponse.json(buildPoll({ id: 12, question: 'Same poll' })),
|
||||
),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Same poll');
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Same poll');
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByPlaceholderText(/what should we do/i)).not.toBeInTheDocument(),
|
||||
);
|
||||
expect(screen.getAllByText('Same poll')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-021: submitting the create form without enough options is a no-op', async () => {
|
||||
let postCalled = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', () => {
|
||||
postCalled = true;
|
||||
return HttpResponse.json({ poll: buildPoll() });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Not enough');
|
||||
fireEvent.submit(screen.getByPlaceholderText(/what should we do/i).closest('form')!);
|
||||
await waitFor(() => expect(screen.getByPlaceholderText(/what should we do/i)).toBeInTheDocument());
|
||||
expect(postCalled).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-022: an extra option can be removed again', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
await user.click(screen.getByText(/add option/i));
|
||||
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(3);
|
||||
|
||||
const thirdRow = screen.getAllByPlaceholderText(/option/i)[2].parentElement!;
|
||||
await user.click(thirdRow.querySelector('button')!);
|
||||
expect(screen.getAllByPlaceholderText(/option/i)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-023: the multi-choice toggle flips and is sent along on create', async () => {
|
||||
let body: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/trips/1/collab/polls', async ({ request }) => {
|
||||
body = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ poll: buildPoll({ id: 30, question: 'Multi?' }) });
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText(/no polls yet|collab\.polls\.empty/i);
|
||||
await user.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
const toggle = screen.getByText(/multiple|multi/i).previousElementSibling as HTMLElement;
|
||||
expect(toggle.style.background).toBe('var(--border-primary)');
|
||||
await user.click(toggle);
|
||||
expect(toggle.style.background).toBe('rgb(0, 122, 255)');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(/what should we do/i), 'Multi?');
|
||||
const optionInputs = screen.getAllByPlaceholderText(/option/i);
|
||||
await user.type(optionInputs[0], 'A');
|
||||
await user.type(optionInputs[1], 'B');
|
||||
await user.click(screen.getByRole('button', { name: /create|collab\.polls\.create/i }));
|
||||
await screen.findByText('Multi?');
|
||||
expect(body).toMatchObject({ multiple_choice: true, options: ['A', 'B'] });
|
||||
});
|
||||
|
||||
it('FE-W5CPL-024: WebSocket events without a type or a known id are ignored', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 40 })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Best destination?');
|
||||
const handler = wsHandler();
|
||||
await act(async () => {
|
||||
handler({});
|
||||
handler({ type: 'collab:poll:deleted' });
|
||||
handler({ type: 'collab:poll:created', poll: { id: 40, question: 'Best destination?' } });
|
||||
});
|
||||
expect(screen.getAllByText('Best destination?')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-W5CPL-025: WebSocket vote and close events update only the matching poll', async () => {
|
||||
servePolls({ polls: [buildPoll({ id: 41 }), buildPoll({ id: 42, question: 'Other poll' })] });
|
||||
render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('Other poll');
|
||||
const handler = wsHandler();
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:voted', poll: buildPoll({ id: 41, question: 'Voted live' }) });
|
||||
});
|
||||
expect(await screen.findByText('Voted live')).toBeInTheDocument();
|
||||
expect(screen.getByText('Other poll')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:closed', poll: { id: 41 } });
|
||||
});
|
||||
await waitFor(() => expect(screen.getAllByText('Closed')).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
handler({ type: 'collab:poll:deleted', poll: { id: 42 } });
|
||||
});
|
||||
await waitFor(() => expect(screen.queryByText('Other poll')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-W5CPL-026: a poll with a live deadline starts the countdown ticker', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
servePolls({ polls: [buildPoll({ deadline: inFuture(90 * MINUTE) })] });
|
||||
const { unmount } = render(<CollabPolls {...defaultProps} />);
|
||||
await screen.findByText('1h 30m');
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(31_000); });
|
||||
expect(screen.getByText('1h 29m')).toBeInTheDocument();
|
||||
unmount();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
@@ -25,7 +26,7 @@ interface Poll {
|
||||
id: number
|
||||
question: string
|
||||
options: PollOption[]
|
||||
multi_choice: boolean
|
||||
multiple_choice: boolean
|
||||
is_closed: boolean
|
||||
deadline: string | null
|
||||
created_by: number
|
||||
@@ -58,7 +59,7 @@ function totalVotes(poll) {
|
||||
// ── Create Poll Modal ────────────────────────────────────────────────────────
|
||||
interface CreatePollModalProps {
|
||||
onClose: () => void
|
||||
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
|
||||
onCreate: (data: { question: string; options: string[]; multiple_choice: boolean }) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
@@ -79,7 +80,9 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multi_choice: multiChoice })
|
||||
// `multiple_choice` is the field the server reads (nest/collab/collab.service.ts);
|
||||
// the old `multi_choice` name was silently dropped, losing desktop multi-choice.
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
|
||||
onClose()
|
||||
} catch {} finally { setSubmitting(false) }
|
||||
}
|
||||
@@ -95,7 +98,7 @@ function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder')} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 'calc(13px * var(--fs-scale-body, 1))', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -205,6 +208,8 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
|
||||
// Highest vote count across the options; 0 for a poll without options.
|
||||
const topCount = (poll.options || []).reduce((max, o) => Math.max(max, o.voters?.length || 0), 0)
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -231,7 +236,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multi_choice && (
|
||||
{poll.multiple_choice && (
|
||||
<span style={{ fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
@@ -268,10 +273,12 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
const count = opt.voters?.length || 0
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
||||
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
|
||||
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
|
||||
const isWinner = isClosed && count > 0 && count === topCount
|
||||
|
||||
return (
|
||||
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
|
||||
// React dispatches no mouse events on a disabled control, so the
|
||||
// handlers below need no isClosed guard of their own.
|
||||
<button key={idx} onClick={() => onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -279,7 +286,7 @@ function PollCard({ poll, currentUser, canEdit, onVote, onClose, onDelete, t }:
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.01)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
@@ -461,11 +468,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{polls.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
|
||||
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
</div>
|
||||
<EmptyState scene="polls" title={t('collab.polls.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
@@ -475,7 +478,7 @@ export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
{t('collab.polls.closedSection')}
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
|
||||
@@ -3,7 +3,8 @@ import { avatarSrc } from '../../utils/avatarSrc'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
import { MapPin, Clock, Users, Sparkles } from 'lucide-react'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
@@ -100,11 +101,7 @@ export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetPro
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
<EmptyState scene="guide" title={t('collab.whatsNext.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
// FE-COMP-ADDPLACECOL-001 to FE-COMP-ADDPLACECOL-024
|
||||
import React from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import type { Category } from '@trek/shared'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import AddPlaceToCollectionModal from './AddPlaceToCollectionModal'
|
||||
|
||||
type Props = React.ComponentProps<typeof AddPlaceToCollectionModal>
|
||||
function Harness(props: Omit<Props, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return <AddPlaceToCollectionModal {...props} t={t} />
|
||||
}
|
||||
|
||||
const categories: Category[] = [
|
||||
{ id: 3, name: 'Food', color: '#f97316', icon: 'utensils' },
|
||||
{ id: 4, name: 'Museum', color: '', icon: 'landmark' },
|
||||
]
|
||||
|
||||
const mapsResult = {
|
||||
name: 'Kissa Sakaiki',
|
||||
address: 'Shibuya, Tokyo',
|
||||
lat: 35.66,
|
||||
lng: 139.7,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: 'osm-1',
|
||||
website: 'https://kissa.example',
|
||||
phone: '+81 3 0000 0000',
|
||||
}
|
||||
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
let addToast: Mock<AddToast>
|
||||
/** Bodies POSTed to the save endpoint, newest last. */
|
||||
let savedBodies: Record<string, unknown>[]
|
||||
|
||||
function mockSearch(places: Record<string, unknown>[]): void {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ places, source: 'nominatim' })))
|
||||
}
|
||||
|
||||
function mockSave(response: Record<string, unknown> = { place: { id: 99 } }): void {
|
||||
server.use(
|
||||
http.post('/api/addons/collections/places', async ({ request }) => {
|
||||
savedBodies.push((await request.json()) as Record<string, unknown>)
|
||||
return HttpResponse.json(response)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function setup(over: Partial<Omit<Props, 't'>> = {}) {
|
||||
const props: Omit<Props, 't'> = {
|
||||
isOpen: true,
|
||||
collectionId: 7,
|
||||
collectionName: 'Tokyo 2026',
|
||||
categories,
|
||||
onClose: vi.fn(),
|
||||
onAdded: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, props }
|
||||
}
|
||||
|
||||
/** Fills the one field the save button waits on. */
|
||||
function typeName(value: string): void {
|
||||
fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value } })
|
||||
}
|
||||
|
||||
describe('AddPlaceToCollectionModal', () => {
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn<AddToast>(() => 0)
|
||||
window.__addToast = addToast
|
||||
savedBodies = []
|
||||
mockSave()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-001: a closed modal renders nothing', () => {
|
||||
setup({ isOpen: false })
|
||||
expect(screen.queryByRole('heading', { name: 'Add a place' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-002: renders the search, form fields and status chips', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('heading', { name: 'Add a place' })).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Search for a place…')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Idea/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Want to go/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Visited/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Add$/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-003: the search button is inert until something is typed', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /Search/ })).toBeDisabled()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-004: Enter runs the search and lists the results', async () => {
|
||||
mockSearch([mapsResult, { name: 'No address place' }])
|
||||
setup()
|
||||
const input = screen.getByPlaceholderText('Search for a place…')
|
||||
fireEvent.change(input, { target: { value: 'kissa' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(await screen.findByText('Kissa Sakaiki')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shibuya, Tokyo')).toBeInTheDocument()
|
||||
expect(screen.getByText('No address place')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-005: picking a result fills name, address and coordinates', async () => {
|
||||
mockSearch([mapsResult])
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Kissa Sakaiki/ }))
|
||||
|
||||
expect(screen.getByPlaceholderText('Name')).toHaveValue('Kissa Sakaiki')
|
||||
expect(screen.getByPlaceholderText('Street, City, Country')).toHaveValue('Shibuya, Tokyo')
|
||||
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('35.66')
|
||||
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('139.7')
|
||||
// The dropdown collapses once a result is taken.
|
||||
expect(screen.queryByText('Shibuya, Tokyo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-006: a result without name or coordinates leaves those fields blank', async () => {
|
||||
mockSearch([{ address: 'Somewhere' }])
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'x' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
fireEvent.click(await screen.findByText('Somewhere'))
|
||||
|
||||
expect(screen.getByPlaceholderText('Name')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('')
|
||||
// The query keeps what the user typed when the result carries no name.
|
||||
expect(screen.getByPlaceholderText('Search for a place…')).toHaveValue('x')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-007: the result dropdown can be dismissed', async () => {
|
||||
mockSearch([mapsResult])
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
await screen.findByText('Kissa Sakaiki')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(screen.queryByText('Kissa Sakaiki')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-008: a failing search toasts the provider error', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ error: 'Places API disabled' }, { status: 500 })))
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Places API disabled', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-009: a search response without places yields no dropdown', async () => {
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ source: 'nominatim' })))
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Search/ })).not.toBeDisabled())
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-010: saving posts the manually typed place with nulls for what is empty', async () => {
|
||||
const { props } = setup()
|
||||
typeName(' Manual spot ')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({
|
||||
collection_id: 7,
|
||||
name: 'Manual spot',
|
||||
address: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
google_place_id: null,
|
||||
osm_id: null,
|
||||
website: null,
|
||||
category_id: null,
|
||||
description: null,
|
||||
links: [],
|
||||
status: 'idea',
|
||||
force: true,
|
||||
})
|
||||
expect(addToast).toHaveBeenCalledWith('Added to Tokyo 2026', 'success', undefined)
|
||||
expect(props.onAdded).toHaveBeenCalledTimes(1)
|
||||
// The form clears so another place can be added straight away.
|
||||
await waitFor(() => expect(screen.getByPlaceholderText('Name')).toHaveValue(''))
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-011: provenance from a picked result rides along with the save', async () => {
|
||||
mockSearch([mapsResult])
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search for a place…'), { target: { value: 'kissa' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Kissa Sakaiki/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({
|
||||
name: 'Kissa Sakaiki',
|
||||
address: 'Shibuya, Tokyo',
|
||||
lat: 35.66,
|
||||
lng: 139.7,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: 'osm-1',
|
||||
website: 'https://kissa.example',
|
||||
phone: '+81 3 0000 0000',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-012: a duplicate response warns instead of reporting a new place', async () => {
|
||||
mockSave({ duplicate: true })
|
||||
const { props } = setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('This place is already in the list', 'info', undefined))
|
||||
expect(props.onAdded).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-013: a failing save surfaces the server error and keeps the form', async () => {
|
||||
server.use(http.post('/api/addons/collections/places', () => HttpResponse.json({ error: 'List is full' }, { status: 400 })))
|
||||
const { props } = setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined))
|
||||
expect(props.onAdded).not.toHaveBeenCalled()
|
||||
expect(screen.getByPlaceholderText('Name')).toHaveValue('Manual spot')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-014: an address and coordinates typed by hand are kept, blanks stay null', async () => {
|
||||
setup()
|
||||
typeName('GPS only')
|
||||
fireEvent.change(screen.getByPlaceholderText('Street, City, Country'), { target: { value: ' 5 Rue Cler, Paris ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Latitude (e.g. 48.8566)'), { target: { value: '48.8566' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({ address: '5 Rue Cler, Paris', lat: 48.8566, lng: null })
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-015: pasting a coordinate pair splits it across both fields', () => {
|
||||
setup()
|
||||
const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)')
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => ' 48.8566, 2.3522 ' } })
|
||||
expect(lat).toHaveValue('48.8566')
|
||||
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-015b: the pair is split the same way when pasted into longitude', () => {
|
||||
setup()
|
||||
const lng = screen.getByPlaceholderText('Longitude (e.g. 2.3522)')
|
||||
fireEvent.paste(lng, { clipboardData: { getData: () => '48.8566, 2.3522' } })
|
||||
expect(screen.getByPlaceholderText('Latitude (e.g. 48.8566)')).toHaveValue('48.8566')
|
||||
expect(lng).toHaveValue('2.3522')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-016: a paste that is not a coordinate pair is left to the input', () => {
|
||||
setup()
|
||||
const lat = screen.getByPlaceholderText('Latitude (e.g. 48.8566)')
|
||||
fireEvent.paste(lat, { clipboardData: { getData: () => 'Eiffel Tower' } })
|
||||
expect(lat).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-017: the chosen status is saved', async () => {
|
||||
setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Visited/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({ status: 'visited' })
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-018: a category can be picked and cleared again', async () => {
|
||||
setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Food/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Museum/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({ category_id: 4 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'No category' }))
|
||||
typeName('Second spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(2))
|
||||
expect(savedBodies[1]).toMatchObject({ category_id: null })
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-019: without categories the category row is not rendered', () => {
|
||||
setup({ categories: [] })
|
||||
expect(screen.queryByText('Category')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'No category' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-020: links are normalised and blank rows dropped', async () => {
|
||||
setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
const urls = screen.getAllByPlaceholderText('https://…')
|
||||
fireEvent.change(screen.getAllByPlaceholderText('Label')[0], { target: { value: ' Menu ' } })
|
||||
fireEvent.change(urls[0], { target: { value: 'kissa.example/menu' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0].links).toEqual([{ label: 'Menu', url: 'https://kissa.example/menu' }])
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-021: a link row can be removed again', () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
expect(screen.getByPlaceholderText('https://…')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }))
|
||||
expect(screen.queryByPlaceholderText('https://…')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-022: a description renders a live markdown preview and is trimmed on save', async () => {
|
||||
setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: ' **Great** coffee ' } })
|
||||
expect(screen.getByText('Great')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add$/ }))
|
||||
await waitFor(() => expect(savedBodies).toHaveLength(1))
|
||||
expect(savedBodies[0]).toMatchObject({ description: '**Great** coffee' })
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-023: closing the modal resets the form for the next open', () => {
|
||||
const { rerender, props } = setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: 'notes' } })
|
||||
|
||||
rerender(<Harness {...props} isOpen={false} />)
|
||||
rerender(<Harness {...props} isOpen />)
|
||||
|
||||
expect(screen.getByPlaceholderText('Name')).toHaveValue('')
|
||||
expect(screen.getByPlaceholderText('Add a description…')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('FE-COMP-ADDPLACECOL-024: Cancel closes without saving', () => {
|
||||
const { props } = setup()
|
||||
typeName('Manual spot')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(savedBodies).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { Search, MapPin, Plus, Loader2, Link2, Trash2, Check, X } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
@@ -44,6 +45,11 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
// The picked location (address/coords/ids) plus the editable fields.
|
||||
const [picked, setPicked] = useState<MapsPlace | null>(null)
|
||||
const [name, setName] = useState('')
|
||||
// Address + coordinates: prefilled from a picked result, but also directly
|
||||
// typeable so a place can be added by GPS without searching (#1435).
|
||||
const [address, setAddress] = useState('')
|
||||
const [lat, setLat] = useState('')
|
||||
const [lng, setLng] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [links, setLinks] = useState<CollectionLink[]>([])
|
||||
@@ -51,7 +57,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
const [saving, setSaving] = useState(false)
|
||||
const descRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
const reset = () => { setQuery(''); setResults([]); setPicked(null); setName(''); setAddress(''); setLat(''); setLng(''); setCategoryId(null); setDescription(''); setLinks([]); setStatus('idea') }
|
||||
useEffect(() => { if (!isOpen) reset() }, [isOpen])
|
||||
|
||||
const search = async () => {
|
||||
@@ -67,21 +73,31 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const pick = (r: MapsPlace) => { setPicked(r); setName(str(r.name) ?? ''); setResults([]); setQuery(str(r.name) ?? query) }
|
||||
const pick = (r: MapsPlace) => {
|
||||
setPicked(r)
|
||||
setName(str(r.name) ?? '')
|
||||
setAddress(str(r.address) ?? '')
|
||||
const la = num(r.lat); const lo = num(r.lng)
|
||||
setLat(la != null ? String(la) : '')
|
||||
setLng(lo != null ? String(lo) : '')
|
||||
setResults([]); setQuery(str(r.name) ?? query)
|
||||
}
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
|
||||
const save = async () => {
|
||||
const cleanName = name.trim()
|
||||
if (!cleanName) return
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await collectionsApi.savePlace({
|
||||
collection_id: collectionId,
|
||||
name: cleanName,
|
||||
address: (picked && str(picked.address)) ?? null,
|
||||
lat: (picked && num(picked.lat)) ?? null,
|
||||
lng: (picked && num(picked.lng)) ?? null,
|
||||
address: address.trim() || null,
|
||||
lat: Number.isFinite(latNum) ? latNum : null,
|
||||
lng: Number.isFinite(lngNum) ? lngNum : null,
|
||||
google_place_id: (picked && str(picked.google_place_id)) ?? null,
|
||||
google_ftid: (picked && str(picked.google_ftid)) ?? null,
|
||||
osm_id: (picked && str(picked.osm_id)) ?? null,
|
||||
@@ -103,7 +119,12 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
}
|
||||
}
|
||||
|
||||
const address = picked ? str(picked.address) : undefined
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const coordInputClass = 'w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent'
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -163,7 +184,16 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('common.name')}</label>
|
||||
<input value={name} onChange={e => setName(e.target.value)} placeholder={t('common.name')} className="w-full px-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[14px] outline-none focus:border-accent" />
|
||||
{address && <div className="flex items-center gap-1.5 mt-1.5 text-[12px] text-content-faint"><MapPin size={12} /> {address}</div>}
|
||||
</div>
|
||||
|
||||
{/* Address + coordinates — editable so a place can be added by GPS alone */}
|
||||
<div>
|
||||
<label className="block text-[12px] font-medium text-content-secondary mb-1.5">{t('places.formAddress')}</label>
|
||||
<input value={address} onChange={e => setAddress(e.target.value)} placeholder={t('places.formAddressPlaceholder')} className={coordInputClass} />
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className={coordInputClass} />
|
||||
<NumericInput mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={coordInputClass} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// FE-COMP-BULKLABEL-001 to FE-COMP-BULKLABEL-009
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionLabel } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import BulkAssignLabelModal from './BulkAssignLabelModal';
|
||||
|
||||
type ModalProps = Omit<React.ComponentProps<typeof BulkAssignLabelModal>, 't'>;
|
||||
|
||||
function Harness(props: ModalProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <BulkAssignLabelModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const berlin: CollectionLabel = { id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' };
|
||||
const food: CollectionLabel = { id: 2, collection_id: 10, name: 'Food', color: null };
|
||||
|
||||
function renderModal(overrides: Partial<ModalProps> = {}) {
|
||||
const props: ModalProps = {
|
||||
isOpen: true,
|
||||
labels: [berlin, food],
|
||||
count: 4,
|
||||
onAssign: vi.fn(async () => {}),
|
||||
onManage: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('BulkAssignLabelModal', () => {
|
||||
it('FE-COMP-BULKLABEL-001: titles the modal with the selection count and lists every label', () => {
|
||||
renderModal({ count: 4 });
|
||||
expect(screen.getByRole('heading', { name: 'Add labels to 4 places' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Berlin' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Food' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-002: with no labels it explains why and offers the manager instead', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderModal({ labels: [] });
|
||||
|
||||
expect(screen.getByText('Create a label first to group places in this list.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Assign label' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Manage labels/ }));
|
||||
expect(props.onManage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-003: the assign button is disabled until at least one label is picked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const assign = screen.getByRole('button', { name: /^Assign label$/ });
|
||||
expect(assign).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Berlin' }));
|
||||
expect(assign).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-004: picking a label marks the row selected, clicking again unpicks it', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
const berlinRow = screen.getByRole('button', { name: 'Berlin' });
|
||||
|
||||
await user.click(berlinRow);
|
||||
expect(berlinRow).toHaveClass('border-accent');
|
||||
|
||||
await user.click(berlinRow);
|
||||
expect(berlinRow).not.toHaveClass('border-accent');
|
||||
expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-005: assigning sends every picked id in click order and clears the selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Food' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Berlin' }));
|
||||
await user.click(screen.getByRole('button', { name: /^Assign label$/ }));
|
||||
|
||||
await waitFor(() => expect(props.onAssign).toHaveBeenCalledWith([2, 1]));
|
||||
// Selection resets so the modal is ready for the next batch.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /^Assign label$/ })).toBeDisabled());
|
||||
expect(screen.getByRole('button', { name: 'Berlin' })).not.toHaveClass('border-accent');
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-006: a second click while the assign is pending is ignored', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAssign = vi.fn(() => new Promise<void>(() => {}));
|
||||
renderModal({ onAssign });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Berlin' }));
|
||||
const assign = screen.getByRole('button', { name: /^Assign label$/ });
|
||||
await user.click(assign);
|
||||
await user.click(assign);
|
||||
|
||||
expect(onAssign).toHaveBeenCalledTimes(1);
|
||||
expect(assign.querySelector('.animate-spin')).not.toBeNull();
|
||||
expect(assign).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-007: Cancel closes without assigning anything', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Berlin' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(props.onAssign).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-008: the footer manage shortcut opens the label manager', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderModal();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Manage labels/ }));
|
||||
expect(props.onManage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-BULKLABEL-009: renders nothing while closed', () => {
|
||||
renderModal({ isOpen: false });
|
||||
expect(screen.queryByRole('heading', { name: /Add labels to/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,14 @@ function makeProps(overrides: Partial<HarnessProps> = {}): HarnessProps {
|
||||
counts: { all: 3, idea: 1, want: 1, visited: 1 },
|
||||
categoryFilter: 'all',
|
||||
categoryOptions: CATEGORY_OPTIONS,
|
||||
ratingFilter: 'all',
|
||||
sortMode: 'default',
|
||||
onStatusFilter: vi.fn(),
|
||||
onCategoryFilter: vi.fn(),
|
||||
onRatingFilter: vi.fn(),
|
||||
onSortMode: vi.fn(),
|
||||
canAddPlace: false,
|
||||
onAddPlace: vi.fn(),
|
||||
showLabels: false,
|
||||
labelOptions: [],
|
||||
labelFilter: [],
|
||||
@@ -49,9 +55,9 @@ beforeEach(() => {
|
||||
describe('CollectionFilterBar', () => {
|
||||
it('FE-COMP-COLFILTERBAR-001: renders the status dropdown showing the current "All" filter', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Both dropdown triggers currently read "All" (status=all, category=all).
|
||||
// With a category present there are exactly two "All" triggers: status + category.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
// Three dropdown triggers read "All" (status=all, category=all, rating=all):
|
||||
// status + category + the #1435 rating filter.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-002: opening the status dropdown reveals the status options', async () => {
|
||||
@@ -84,15 +90,15 @@ describe('CollectionFilterBar', () => {
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-004: the category dropdown is present when categoryOptions is non-empty', () => {
|
||||
render(<Harness {...makeProps()} />);
|
||||
// Two dropdown triggers = status + category.
|
||||
// Three dropdown triggers = status + category + rating.
|
||||
const triggers = screen.getAllByRole('button', { name: 'All' });
|
||||
expect(triggers).toHaveLength(2);
|
||||
expect(triggers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-005: the category dropdown is hidden when categoryOptions is empty', () => {
|
||||
render(<Harness {...makeProps({ categoryOptions: [] })} />);
|
||||
// Only the status dropdown remains.
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(1);
|
||||
// The status + rating dropdowns remain (the rating filter always shows).
|
||||
expect(screen.getAllByRole('button', { name: 'All' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLFILTERBAR-006: clicking a category option calls onCategoryFilter with the category id', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare } from 'lucide-react'
|
||||
import type { StatusFilter } from '../../store/collectionStore'
|
||||
import { ChevronDown, Check, Layers, Tag, Tags, CheckSquare, Star, Plus, ArrowDownUp } from 'lucide-react'
|
||||
import type { StatusFilter, CollectionSortMode } from '../../store/collectionStore'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
@@ -67,8 +67,15 @@ interface CollectionFilterBarProps {
|
||||
counts: Record<StatusFilter, number>
|
||||
categoryFilter: number | 'all'
|
||||
categoryOptions: CategoryOption[]
|
||||
ratingFilter: number | 'all'
|
||||
sortMode: CollectionSortMode
|
||||
onStatusFilter: (f: StatusFilter) => void
|
||||
onCategoryFilter: (f: number | 'all') => void
|
||||
onRatingFilter: (f: number | 'all') => void
|
||||
onSortMode: (m: CollectionSortMode) => void
|
||||
// Add a place to the current list — leads the row when the list is editable.
|
||||
canAddPlace: boolean
|
||||
onAddPlace: () => void
|
||||
// Per-collection labels (hidden on the "All saved" union).
|
||||
showLabels: boolean
|
||||
labelOptions: LabelOption[]
|
||||
@@ -88,7 +95,9 @@ interface CollectionFilterBarProps {
|
||||
* Custom compact dropdowns so they barely take any space.
|
||||
*/
|
||||
export default function CollectionFilterBar({
|
||||
statusFilter, counts, categoryFilter, categoryOptions, onStatusFilter, onCategoryFilter,
|
||||
statusFilter, counts, categoryFilter, categoryOptions, ratingFilter, sortMode,
|
||||
onStatusFilter, onCategoryFilter, onRatingFilter, onSortMode,
|
||||
canAddPlace, onAddPlace,
|
||||
showLabels, labelOptions, labelFilter, onLabelFilter, canManageLabels, onManageLabels,
|
||||
showSelect, selectMode, onToggleSelect, t,
|
||||
}: CollectionFilterBarProps): React.ReactElement {
|
||||
@@ -109,12 +118,35 @@ export default function CollectionFilterBar({
|
||||
}),
|
||||
]
|
||||
|
||||
// Minimum-average-rating filter (#1435): All, then ≥5…≥1 stars.
|
||||
const ratingOpts: Opt[] = [
|
||||
{ key: 'all', label: t('common.all') },
|
||||
...[5, 4, 3, 2, 1].map(n => ({
|
||||
key: n,
|
||||
label: `${n}+`,
|
||||
icon: <Star size={13} color="#facc15" fill="#facc15" />,
|
||||
})),
|
||||
]
|
||||
|
||||
// Display order: the saved order, or alphabetical by name.
|
||||
const sortOpts: Opt[] = [
|
||||
{ key: 'default', label: t('collections.sort.default') },
|
||||
{ key: 'name_asc', label: t('collections.sort.nameAsc') },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="col-filterbar">
|
||||
{canAddPlace && (
|
||||
<button type="button" onClick={onAddPlace} className="col-filter-btn col-filter-add" aria-label={t('collections.addPlace')} title={t('collections.addPlace')}>
|
||||
<Plus size={15} />
|
||||
</button>
|
||||
)}
|
||||
<Dropdown current={statusFilter} options={statusOpts} onSelect={k => onStatusFilter(k as StatusFilter)} lead={<Layers size={13} />} />
|
||||
{categoryOptions.length > 0 && (
|
||||
<Dropdown current={categoryFilter} options={catOpts} onSelect={k => onCategoryFilter(k as number | 'all')} lead={<Tag size={13} />} />
|
||||
)}
|
||||
<Dropdown current={ratingFilter} options={ratingOpts} onSelect={k => onRatingFilter(k as number | 'all')} lead={<Star size={13} />} />
|
||||
<Dropdown current={sortMode} options={sortOpts} onSelect={k => onSortMode(k as CollectionSortMode)} lead={<ArrowDownUp size={13} />} />
|
||||
{showSelect && (
|
||||
<button type="button" onClick={onToggleSelect} className={`col-filter-btn col-filter-select${selectMode ? ' open' : ''}`} aria-pressed={selectMode}>
|
||||
<CheckSquare size={14} /> <span className="col-filter-lbl">{t('collections.select')}</span>
|
||||
@@ -140,7 +172,7 @@ export default function CollectionFilterBar({
|
||||
)
|
||||
})}
|
||||
{canManageLabels && (
|
||||
<button type="button" className="col-labelchip col-labelchip-manage" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<button type="button" className="col-filter-btn col-filter-addlabel" onClick={onManageLabels} title={t('collections.labels.manage')}>
|
||||
<Tags size={13} />
|
||||
<span className="col-filter-lbl">{labelOptions.length ? t('collections.labels.manage') : t('collections.labels.add')}</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// FE-COMP-COLHERO-001 to FE-COMP-COLHERO-011
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionMember } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionHero from './CollectionHero';
|
||||
|
||||
type HeroProps = Omit<React.ComponentProps<typeof CollectionHero>, 't'>;
|
||||
|
||||
function Harness(props: HeroProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CollectionHero {...props} t={t} />;
|
||||
}
|
||||
|
||||
function member(over: Partial<CollectionMember> = {}): CollectionMember {
|
||||
return { user_id: 1, username: 'ada lovelace', status: 'accepted', ...over } as CollectionMember;
|
||||
}
|
||||
|
||||
function renderHero(overrides: Partial<HeroProps> = {}) {
|
||||
const props: HeroProps = {
|
||||
eyebrow: 'Private list',
|
||||
title: 'Weekend in Rome',
|
||||
color: '#ef4444',
|
||||
members: [],
|
||||
canShare: true,
|
||||
isOwner: true,
|
||||
canEdit: true,
|
||||
onEdit: vi.fn(),
|
||||
shareMemberCount: 0,
|
||||
onShare: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('CollectionHero', () => {
|
||||
it('FE-COMP-COLHERO-001: renders the eyebrow, the title and the colour wash', () => {
|
||||
renderHero();
|
||||
expect(screen.getByRole('heading', { name: 'Weekend in Rome' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Private list')).toBeInTheDocument();
|
||||
// No cover image → the gradient background element stands in for it.
|
||||
expect(document.querySelector('.col-hero-bg')).not.toBeNull();
|
||||
expect(document.querySelector('.col-hero-img')).toBeNull();
|
||||
expect(document.querySelector<HTMLElement>('.col-hero')?.style.getPropertyValue('--hero-color')).toBe('#ef4444');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-002: a cover image replaces the gradient and adds the tint layer', () => {
|
||||
renderHero({ coverImage: '/uploads/covers/rome.jpg' });
|
||||
expect(document.querySelector<HTMLImageElement>('.col-hero-img')?.getAttribute('src')).toBe('/uploads/covers/rome.jpg');
|
||||
expect(document.querySelector('.col-hero-tint')).not.toBeNull();
|
||||
expect(document.querySelector('.col-hero-bg')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-003: the description renders only when present', () => {
|
||||
const { unmount } = render(<Harness {...{
|
||||
eyebrow: 'x', title: 'y', color: '#000', members: [], canShare: false, isOwner: false,
|
||||
canEdit: false, onEdit: vi.fn(), shareMemberCount: 0, onShare: vi.fn(),
|
||||
}} />);
|
||||
expect(document.querySelector('.col-hero-desc')).toBeNull();
|
||||
unmount();
|
||||
|
||||
renderHero({ description: 'Three days of pasta' });
|
||||
expect(screen.getByText('Three days of pasta')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-004: a single member does not produce an avatar stack', () => {
|
||||
renderHero({ members: [member({ user_id: 1, is_owner: true })] });
|
||||
expect(document.querySelector('.members')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-005: two or more accepted members render initials avatars', () => {
|
||||
renderHero({
|
||||
members: [
|
||||
member({ user_id: 1, username: 'ada lovelace', is_owner: true }),
|
||||
member({ user_id: 2, username: 'grace' }),
|
||||
member({ user_id: 3, username: ' ' }),
|
||||
],
|
||||
});
|
||||
const stack = document.querySelector('.members');
|
||||
expect(stack).not.toBeNull();
|
||||
expect(within(stack as HTMLElement).getByText('AL')).toBeInTheDocument();
|
||||
expect(within(stack as HTMLElement).getByText('G')).toBeInTheDocument();
|
||||
// A blank username still gets a placeholder rather than an empty circle.
|
||||
expect(within(stack as HTMLElement).getByText('?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-006: pending members are excluded, owners are kept regardless of status', () => {
|
||||
renderHero({
|
||||
members: [
|
||||
member({ user_id: 1, username: 'ada', status: 'pending', is_owner: true }),
|
||||
member({ user_id: 2, username: 'grace' }),
|
||||
member({ user_id: 3, username: 'nobody', status: 'pending' }),
|
||||
],
|
||||
});
|
||||
const stack = document.querySelector('.members') as HTMLElement;
|
||||
expect(within(stack).getByText('A')).toBeInTheDocument();
|
||||
expect(within(stack).getByText('G')).toBeInTheDocument();
|
||||
expect(within(stack).queryByText('N')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-007: an uploaded avatar renders as an image, and beyond five members a +N chip appears', () => {
|
||||
const members = [1, 2, 3, 4, 5, 6, 7].map(i =>
|
||||
member({ user_id: i, username: `user${i}`, avatar: i === 1 ? 'me.png' : null }),
|
||||
);
|
||||
renderHero({ members });
|
||||
const stack = document.querySelector('.members') as HTMLElement;
|
||||
expect(within(stack).getByRole('img', { name: 'user1' })).toHaveAttribute('src', '/uploads/avatars/me.png');
|
||||
expect(within(stack).getByText('+2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-008: link chips show the label, or the bare host when unlabelled', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderHero({
|
||||
links: [
|
||||
{ url: 'https://www.romeguide.example/food', label: 'Food guide' },
|
||||
{ url: 'https://maps.example/pin' },
|
||||
{ url: 'not-a-url' },
|
||||
],
|
||||
});
|
||||
const labelled = screen.getByRole('link', { name: /Food guide/ });
|
||||
expect(labelled).toHaveAttribute('href', 'https://www.romeguide.example/food');
|
||||
expect(labelled).toHaveAttribute('target', '_blank');
|
||||
// No label → hostname without the www. prefix.
|
||||
expect(screen.getByRole('link', { name: /maps\.example/ })).toBeInTheDocument();
|
||||
// Unparseable href → the raw string is shown rather than crashing.
|
||||
expect(screen.getByRole('link', { name: /not-a-url/ })).toBeInTheDocument();
|
||||
|
||||
// The chip must not bubble a click up to the hero.
|
||||
const onEdit = vi.fn();
|
||||
await user.click(labelled);
|
||||
expect(onEdit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-009: the Edit button renders only when canEdit and calls onEdit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderHero({ canEdit: true });
|
||||
await user.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
expect(props.onEdit).toHaveBeenCalledTimes(1);
|
||||
|
||||
renderHero({ canEdit: false, canShare: false });
|
||||
expect(screen.getAllByRole('button', { name: 'Edit' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-010: the owner sees a Share button carrying the member count badge', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderHero({ isOwner: true, canShare: true, shareMemberCount: 3 });
|
||||
const share = screen.getByRole('button', { name: 'Share' });
|
||||
expect(share).toHaveClass('has-count');
|
||||
expect(within(share).getByText('3')).toBeInTheDocument();
|
||||
await user.click(share);
|
||||
expect(props.onShare).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLHERO-011: a non-owner sees a Shared button without a count badge', () => {
|
||||
renderHero({ isOwner: false, canShare: true, shareMemberCount: 3 });
|
||||
const share = screen.getByRole('button', { name: 'Shared' });
|
||||
expect(share).not.toHaveClass('has-count');
|
||||
expect(within(share).queryByText('3')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
// FE-COMP-COLPICKER-001 to FE-COMP-COLPICKER-016
|
||||
import React from 'react'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
|
||||
import type { Collection, CollectionDetailResponse, CollectionPlace } from '@trek/shared'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser } from '../../../tests/helpers/factories'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import CollectionPicker from './CollectionPicker'
|
||||
|
||||
type Props = React.ComponentProps<typeof CollectionPicker>
|
||||
function Harness(props: Omit<Props, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return <CollectionPicker {...props} t={t} />
|
||||
}
|
||||
|
||||
const listA: Collection = { id: 1, owner_id: 1, name: 'Tokyo 2026', color: '#ec4899' }
|
||||
const listB: Collection = { id: 2, owner_id: 1, name: 'Rome', color: null }
|
||||
|
||||
function place(over: Partial<CollectionPlace> & { id: number; collection_id: number; name: string }): CollectionPlace {
|
||||
return { status: 'idea', ...over }
|
||||
}
|
||||
|
||||
// Zebra Cafe sits far from the bias box, Alpha Bar right inside it — so the
|
||||
// alphabetical and the proximity order are deliberately opposites.
|
||||
const zebra = place({ id: 10, collection_id: 1, name: 'Zebra Cafe', address: 'Shibuya 1', lat: 35.6, lng: 139.7 })
|
||||
const alpha = place({ id: 11, collection_id: 2, name: 'Alpha Bar', address: 'Trastevere 9', lat: 41.9, lng: 12.5, category: { id: 3, name: 'Bar', color: '#f59e0b', icon: 'beer' } })
|
||||
const noCoords = place({ id: 12, collection_id: 2, name: 'Mystery Spot', status: 'visited' })
|
||||
|
||||
function detail(collection: Collection, places: CollectionPlace[]): CollectionDetailResponse {
|
||||
return { collection, places }
|
||||
}
|
||||
|
||||
function setup(over: Partial<Omit<Props, 't'>> = {}) {
|
||||
const props: Omit<Props, 't'> = { onSelect: vi.fn(), ...over }
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, props }
|
||||
}
|
||||
|
||||
describe('CollectionPicker', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
// Keeps PlaceAvatar from reaching for provider photos in jsdom.
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false })
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ collections: [listA, listB], incomingInvites: [] })
|
||||
vi.spyOn(collectionsApi, 'get').mockImplementation(async (id: number) =>
|
||||
id === 1 ? detail(listA, [zebra]) : detail(listB, [alpha, noCoords]),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-001: shows a spinner until the lists have loaded', async () => {
|
||||
const { container } = setup()
|
||||
expect(container.querySelector('.animate-spin')).toBeTruthy()
|
||||
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
|
||||
expect(container.querySelector('.animate-spin')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-002: merges the places of every list and shows their addresses', async () => {
|
||||
setup()
|
||||
expect(await screen.findByText('Zebra Cafe')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
|
||||
expect(screen.getByText('Shibuya 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Saved places')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-003: sorts alphabetically without a bias box', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
|
||||
expect(names).toEqual(['Alpha BarTrastevere 9', 'Mystery Spot', 'Zebra CafeShibuya 1'])
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-004: a bias box sorts by proximity and pushes coordinate-less places last', async () => {
|
||||
setup({ bias: { low: { lat: 35.5, lng: 139.6 }, high: { lat: 35.7, lng: 139.8 } } })
|
||||
await screen.findByText('Zebra Cafe')
|
||||
const names = screen.getAllByRole('button', { name: /Alpha Bar|Mystery Spot|Zebra Cafe/ }).map(b => b.textContent)
|
||||
expect(names).toEqual(['Zebra CafeShibuya 1', 'Alpha BarTrastevere 9', 'Mystery Spot'])
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-005: picking a place hands the whole record to onSelect', async () => {
|
||||
const { props } = setup()
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Zebra Cafe/ }))
|
||||
expect(props.onSelect).toHaveBeenCalledTimes(1)
|
||||
expect(props.onSelect).toHaveBeenCalledWith(zebra)
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-006: the search box matches on name', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'zebra' } })
|
||||
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-007: the search box also matches on address', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'trastevere' } })
|
||||
expect(screen.getByText('Alpha Bar')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-008: a search with no hits falls back to the empty copy', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.change(screen.getByPlaceholderText('Search your saved places'), { target: { value: 'nothing here' } })
|
||||
expect(screen.getByText('No saved places to add')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-009: the list dropdown counts each list and filters to it', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
||||
|
||||
const menu = screen.getByRole('listbox')
|
||||
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveTextContent('3')
|
||||
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveTextContent('2')
|
||||
|
||||
fireEvent.click(within(menu).getByRole('option', { name: /Tokyo 2026/ }))
|
||||
expect(screen.getByText('Zebra Cafe')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
||||
// The trigger adopts the picked option and the menu closes.
|
||||
expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-010: the status dropdown filters by saved status', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /^All$/ }))
|
||||
fireEvent.click(within(screen.getByRole('listbox')).getByRole('option', { name: /Visited/ }))
|
||||
|
||||
expect(screen.getByText('Mystery Spot')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Alpha Bar')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-011: the selected option is flagged for assistive tech', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
||||
const menu = screen.getByRole('listbox')
|
||||
expect(within(menu).getByRole('option', { name: /All lists/ })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(within(menu).getByRole('option', { name: /Rome/ })).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-012: a click outside closes an open dropdown', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
const trigger = screen.getByRole('button', { name: /All lists/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-013: a mousedown inside the dropdown keeps it open', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
||||
fireEvent.mouseDown(screen.getByRole('listbox'))
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-014: Escape closes an open dropdown', async () => {
|
||||
setup()
|
||||
await screen.findByText('Zebra Cafe')
|
||||
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBeInTheDocument())
|
||||
// A different key leaves it alone.
|
||||
fireEvent.click(screen.getByRole('button', { name: /All lists/ }))
|
||||
fireEvent.keyDown(document, { key: 'a' })
|
||||
expect(screen.getByRole('listbox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-015: a list whose detail request fails is skipped, the rest still load', async () => {
|
||||
vi.mocked(collectionsApi.get).mockImplementation(async (id: number) => {
|
||||
if (id === 1) throw new Error('403')
|
||||
return detail(listB, [alpha])
|
||||
})
|
||||
setup()
|
||||
expect(await screen.findByText('Alpha Bar')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Zebra Cafe')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-016: a failing list request degrades to the empty state without filters', async () => {
|
||||
vi.mocked(collectionsApi.list).mockRejectedValue(new Error('offline'))
|
||||
setup()
|
||||
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLPICKER-017: with no lists at all the filter row stays hidden', async () => {
|
||||
vi.mocked(collectionsApi.list).mockResolvedValue({ collections: [], incomingInvites: [] })
|
||||
setup()
|
||||
expect(await screen.findByText('No saved places to add')).toBeInTheDocument()
|
||||
expect(collectionsApi.get).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: /All lists/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -31,6 +31,28 @@ function distanceTo(p: CollectionPlace, center: { lat: number; lng: number }): n
|
||||
|
||||
interface Opt { key: string | number; label: string; icon?: React.ReactNode; count?: number }
|
||||
|
||||
/** Detail requests fired at once while building the union — keeps a user with
|
||||
* many lists from opening the modal with a burst of parallel requests. */
|
||||
const DETAIL_BATCH = 4
|
||||
|
||||
/** Union of every list's places, in list order, without duplicates. */
|
||||
async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
const merged: CollectionPlace[] = []
|
||||
const seen = new Set<number>()
|
||||
for (let i = 0; i < ids.length; i += DETAIL_BATCH) {
|
||||
const batch = await Promise.all(ids.slice(i, i + DETAIL_BATCH).map(id => collectionsApi.get(id).catch(() => null)))
|
||||
for (const d of batch) {
|
||||
if (!d) continue
|
||||
for (const p of d.places) {
|
||||
if (seen.has(p.id)) continue
|
||||
seen.add(p.id)
|
||||
merged.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
@@ -93,13 +115,8 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
setLoading(true)
|
||||
collectionsApi.list()
|
||||
.then(async (res) => {
|
||||
const detail = await Promise.all(res.collections.map(c => collectionsApi.get(c.id).catch(() => null)))
|
||||
const merged = await loadSavedPlaces(res.collections.map(c => c.id))
|
||||
if (cancelled) return
|
||||
const merged: CollectionPlace[] = []
|
||||
for (const d of detail) {
|
||||
if (!d) continue
|
||||
for (const p of d.places) merged.push(p)
|
||||
}
|
||||
setLists(res.collections.map(c => ({ id: c.id, name: c.name, color: c.color ?? null })))
|
||||
setPlaces(merged)
|
||||
})
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-010
|
||||
// FE-COMP-COLDETAIL-001 to FE-COMP-COLDETAIL-035
|
||||
import React from 'react';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import type { CollectionPlace } from '@trek/shared';
|
||||
import type { CollectionLabel, CollectionPlace } from '@trek/shared';
|
||||
import type { Category } from '../../types';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CollectionPlaceDetail from './CollectionPlaceDetail';
|
||||
|
||||
@@ -33,6 +34,18 @@ const place: CollectionPlace = {
|
||||
category: { id: 1, name: 'Food', color: '#f00', icon: null },
|
||||
};
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
{ id: 1, name: 'Food', color: '#f00', icon: 'utensils' },
|
||||
{ id: 2, name: 'Museums', color: '#00f', icon: 'landmark' },
|
||||
];
|
||||
|
||||
const LABELS: CollectionLabel[] = [
|
||||
{ id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' },
|
||||
{ id: 2, collection_id: 10, name: 'Nightlife', color: null },
|
||||
];
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
const props = {
|
||||
place,
|
||||
@@ -55,6 +68,8 @@ function renderDetail(overrides: Partial<Omit<DetailProps, 't'>> = {}) {
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { user: buildUser(), placesPhotosEnabled: false });
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
// The detail sheet asks the maps provider for a cover photo on mount when a
|
||||
// place carries no image of its own — stub it so nothing hits the network.
|
||||
server.use(
|
||||
@@ -64,6 +79,10 @@ beforeEach(() => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('CollectionPlaceDetail', () => {
|
||||
it('FE-COMP-COLDETAIL-001: renders the place name, address and description', async () => {
|
||||
renderDetail();
|
||||
@@ -139,4 +158,335 @@ describe('CollectionPlaceDetail', () => {
|
||||
await user.click(await screen.findByRole('button', { name: 'Copy to trip' }));
|
||||
expect(props.onCopyToTrip).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Custom cover image (#1136) ──────────────────────────────────────────────
|
||||
it('FE-COMP-COLDETAIL-011: shows the cover upload control when canEdit && onUploadImage', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true, onUploadImage: vi.fn() });
|
||||
const camera = await screen.findByRole('button', { name: 'Upload image' });
|
||||
expect(camera).toBeInTheDocument();
|
||||
|
||||
// The camera button proxies the hidden file input.
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const clicked = vi.fn();
|
||||
input.addEventListener('click', clicked);
|
||||
await user.click(camera);
|
||||
expect(clicked).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-012: hides the cover upload control when onUploadImage is not provided', async () => {
|
||||
renderDetail({ canEdit: true });
|
||||
// Wait for the mount photo effect to settle before asserting absence.
|
||||
expect(await screen.findByRole('button', { name: 'Copy to trip' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Change image' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-013: removing a custom cover calls onSave with { image_url: null }', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
|
||||
const props = renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage });
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
|
||||
expect(props.onSave).toHaveBeenCalledWith({ image_url: null });
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-014: a failing cover removal surfaces the upload error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const withImage: CollectionPlace = { ...place, image_url: '/uploads/places/mock.jpg' };
|
||||
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Disk full' } } }));
|
||||
renderDetail({ canEdit: true, onUploadImage: vi.fn(), place: withImage, onSave });
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove image' }));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Disk full', 'error', undefined));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-015: picking a cover file hands the normalized file to onUploadImage', async () => {
|
||||
const onUploadImage = vi.fn(async (_file: File) => {});
|
||||
renderDetail({ canEdit: true, onUploadImage });
|
||||
await screen.findByRole('button', { name: 'Upload image' });
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' });
|
||||
fireEvent.change(input, { target: { files: [file] } });
|
||||
|
||||
await waitFor(() => expect(onUploadImage).toHaveBeenCalledTimes(1));
|
||||
expect(onUploadImage.mock.calls[0][0]).toBe(file);
|
||||
// The picker resets so re-selecting the same file fires again.
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-016: a cancelled file picker uploads nothing', async () => {
|
||||
const onUploadImage = vi.fn(async () => {});
|
||||
renderDetail({ canEdit: true, onUploadImage });
|
||||
await screen.findByRole('button', { name: 'Upload image' });
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [] } });
|
||||
expect(onUploadImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-017: a failing upload surfaces the error and clears the busy state', async () => {
|
||||
const onUploadImage = vi.fn(() => Promise.reject(new Error('Upload rejected')));
|
||||
renderDetail({ canEdit: true, onUploadImage });
|
||||
await screen.findByRole('button', { name: 'Upload image' });
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { files: [new File(['x'], 'cover.png', { type: 'image/png' })] } });
|
||||
|
||||
// A rejection without a server payload falls back to the localized message.
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined));
|
||||
// The spinner is gone again, so the control accepts a retry.
|
||||
await waitFor(() => expect(document.querySelector('.animate-spin')).toBeNull());
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-018: the cover renders the fetched provider photo when the place has none', async () => {
|
||||
server.use(
|
||||
http.get('/api/maps/place-photo/:id', () =>
|
||||
HttpResponse.json({ photoUrl: 'https://cdn.example/photo.jpg', attribution: null }),
|
||||
),
|
||||
);
|
||||
renderDetail({ place: { ...place, google_place_id: 'gp-1' } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', 'https://cdn.example/photo.jpg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-018b: a place with its own image never asks the provider for a photo', async () => {
|
||||
const photo = vi.fn();
|
||||
server.use(http.get('/api/maps/place-photo/:id', () => { photo(); return HttpResponse.json({ photoUrl: null }); }));
|
||||
renderDetail({ place: { ...place, image_url: '/uploads/places/mine.jpg', google_place_id: 'gp-1' } });
|
||||
|
||||
await screen.findByRole('heading', { name: 'Test Cafe' });
|
||||
expect(document.querySelector('.col-detail-cover img')).toHaveAttribute('src', '/uploads/places/mine.jpg');
|
||||
expect(photo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-019: the category chip and the assigned label chips render in read mode', async () => {
|
||||
renderDetail({ labels: LABELS, place: { ...place, label_ids: [1] } });
|
||||
expect(await screen.findByText('Food')).toBeInTheDocument();
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument();
|
||||
// Only the assigned label shows — the list's other labels stay hidden.
|
||||
expect(screen.queryByText('Nightlife')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-020: the description renders as markdown and links become chips', async () => {
|
||||
renderDetail({
|
||||
place: {
|
||||
...place,
|
||||
description: '# Heading\n\nsome **bold** text',
|
||||
links: [{ url: 'https://www.example.com/menu', label: 'Menu' }, { url: 'https://tickets.example/x' }],
|
||||
},
|
||||
});
|
||||
expect(await screen.findByRole('heading', { name: 'Heading' })).toBeInTheDocument();
|
||||
expect(screen.getByText('bold').tagName).toBe('STRONG');
|
||||
expect(screen.getByRole('link', { name: /Menu/ })).toHaveAttribute('href', 'https://www.example.com/menu');
|
||||
// Unlabelled link falls back to the bare hostname.
|
||||
expect(screen.getByRole('link', { name: /tickets\.example/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-021: the rating control renders only when onRate is supplied', async () => {
|
||||
const onRate = vi.fn(async () => {});
|
||||
renderDetail({ onRate, place: { ...place, rating_avg: 4 } });
|
||||
const stars = await screen.findByRole('radiogroup');
|
||||
await userEvent.setup().click(within(stars).getByRole('radio', { name: '5' }));
|
||||
expect(onRate).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-022: edit mode exposes the category picker and switching category sticks', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true, categories: CATEGORIES, place: { ...place, category_id: 1 } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByText('Category')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Food$/ })).toHaveClass('on');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
|
||||
expect(screen.getByRole('button', { name: /^Museums$/ })).toHaveClass('on');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'No category' }));
|
||||
expect(screen.getByRole('button', { name: 'No category' })).toHaveClass('on');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-023: saving from edit mode sends the whole patch and leaves edit mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({
|
||||
canEdit: true,
|
||||
categories: CATEGORIES,
|
||||
labels: LABELS,
|
||||
place: { ...place, lat: 52.5, lng: 13.4, label_ids: [1] },
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
const nameInput = screen.getByLabelText('List name');
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, ' Renamed Cafe ');
|
||||
await user.click(screen.getByRole('button', { name: /^Museums$/ }));
|
||||
await user.click(screen.getByRole('button', { name: /Nightlife/ }));
|
||||
const desc = screen.getByPlaceholderText('Add a description…');
|
||||
await user.clear(desc);
|
||||
await user.type(desc, 'Great coffee');
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith({
|
||||
name: 'Renamed Cafe',
|
||||
description: 'Great coffee',
|
||||
links: [{ label: undefined, url: 'https://x.com' }],
|
||||
category_id: 2,
|
||||
label_ids: [1, 2],
|
||||
lat: 52.5,
|
||||
lng: 13.4,
|
||||
});
|
||||
// Back in read mode.
|
||||
expect(await screen.findByRole('button', { name: 'Edit' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-024: an empty name and description fall back to the stored name and null', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, place: { ...place, description: 'Nice spot', links: [] } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.clear(screen.getByLabelText('List name'));
|
||||
await user.clear(screen.getByPlaceholderText('Add a description…'));
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'Test Cafe',
|
||||
description: null,
|
||||
lat: null,
|
||||
lng: null,
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-025: a failed save toasts the server message and keeps the form open', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn(() => Promise.reject({ response: { data: { error: 'Name taken' } } }));
|
||||
renderDetail({ canEdit: true, onSave });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined));
|
||||
expect(screen.getByLabelText('List name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-026: Cancel discards the edits and returns to read mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.type(screen.getByLabelText('List name'), ' Extra');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
|
||||
expect(props.onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('heading', { name: 'Test Cafe' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-027: links can be added, relabelled and removed in edit mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, place: { ...place, links: [] } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Add link/ }));
|
||||
await user.type(screen.getByPlaceholderText('Label'), 'Site');
|
||||
await user.type(screen.getByPlaceholderText('https://…'), 'example.com/menu');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
// A bare host is normalized to an absolute https url before it is saved.
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
links: [{ label: 'Site', url: 'https://example.com/menu' }],
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-028: an emptied link row is dropped from the saved patch', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true, place: { ...place, links: [{ url: 'https://x.com' }] } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
// Two rows: the existing link plus a blank one that never gets a url.
|
||||
await user.click(screen.getByRole('button', { name: /Add link/ }));
|
||||
expect(screen.getAllByPlaceholderText('https://…')).toHaveLength(2);
|
||||
|
||||
await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]);
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ links: [] }));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-029: pasting a "lat, lng" pair into the latitude field fills both inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
|
||||
fireEvent.paste(latInput, { clipboardData: { getData: () => ' 48.8566, 2.3522 ' } });
|
||||
|
||||
expect(latInput).toHaveValue('48.8566');
|
||||
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('2.3522');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Save/ }));
|
||||
await waitFor(() => expect(props.onSave).toHaveBeenCalled());
|
||||
expect(props.onSave).toHaveBeenCalledWith(expect.objectContaining({ lat: 48.8566, lng: 2.3522 }));
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-030: a paste that is not a coordinate pair is left to the input', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
const latInput = screen.getByPlaceholderText('Latitude (e.g. 48.8566)');
|
||||
fireEvent.paste(latInput, { clipboardData: { getData: () => 'somewhere nice' } });
|
||||
|
||||
expect(latInput).toHaveValue('');
|
||||
expect(screen.getByPlaceholderText('Longitude (e.g. 2.3522)')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-031: opening a different place resets the form back to read mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canEdit: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
expect(screen.getByLabelText('List name')).toBeInTheDocument();
|
||||
|
||||
const other: CollectionPlace = { ...place, id: 2, name: 'Second Bar', description: null, links: [] };
|
||||
render(<TranslatedDetail {...{ ...props, place: other }} />);
|
||||
|
||||
// The freshly mounted sheet shows the new place in read mode.
|
||||
expect(await screen.findByRole('heading', { name: 'Second Bar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-032: the labels field is omitted when the list defines none', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDetail({ canEdit: true, labels: [] });
|
||||
await user.click(await screen.findByRole('button', { name: 'Edit' }));
|
||||
|
||||
expect(screen.getByText('Coordinates')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-033: the anchor rect docks the sheet over the given column', async () => {
|
||||
renderDetail({ anchorRect: { left: 320, width: 420 } });
|
||||
await screen.findByRole('heading', { name: 'Test Cafe' });
|
||||
|
||||
const sheet = document.querySelector('.col-detail') as HTMLElement;
|
||||
expect(sheet).toHaveClass('docked');
|
||||
expect(sheet.style.left).toBe('320px');
|
||||
expect(sheet.style.width).toBe('420px');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-034: the header close button hands back to the caller', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail();
|
||||
await user.click(await screen.findByRole('button', { name: 'Close' }));
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLDETAIL-035: the remove action fires onRemove for an admin', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderDetail({ canDelete: true });
|
||||
await user.click(await screen.findByRole('button', { name: 'Remove from list' }));
|
||||
expect(props.onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,16 +2,20 @@ import React, { useEffect, useRef, useState } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags } from 'lucide-react'
|
||||
import { X, Pencil, Copy, Trash2, MapPin, Link2, Plus, ExternalLink, Check, Tag, Tags, Camera, Loader2 } from 'lucide-react'
|
||||
import type { CollectionPlace, CollectionStatus, CollectionLink, CollectionLabel } from '@trek/shared'
|
||||
import type { Category, TranslationFn } from '../../types'
|
||||
import MarkdownToolbar from '../Journey/MarkdownToolbar'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { entityGradient } from '../../utils/gradients'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { STATUS_META, STATUS_ORDER, normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import PlaceRating from '../shared/StarRating'
|
||||
import { normalizeImageFile } from '../../utils/convertHeic'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
@@ -28,9 +32,13 @@ interface CollectionPlaceDetailProps {
|
||||
anchorRect?: { left: number; width: number } | null
|
||||
onClose: () => void
|
||||
onSetStatus: (status: CollectionStatus) => void
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[] }) => Promise<void>
|
||||
onSave: (patch: { name?: string; description?: string | null; links?: CollectionLink[]; category_id?: number | null; label_ids?: number[]; image_url?: string | null; lat?: number | null; lng?: number | null }) => Promise<void>
|
||||
/** Upload a custom cover image (#1136); enables the cover change/remove controls. */
|
||||
onUploadImage?: (file: File) => Promise<void>
|
||||
onCopyToTrip: () => void
|
||||
onRemove: () => void
|
||||
/** Cast/clear the current user's star vote (#1435); every member may vote. */
|
||||
onRate?: (rating: number | null) => Promise<void> | void
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -58,15 +66,19 @@ function StatusSegment({ status, onSet, t }: { status: CollectionStatus; onSet:
|
||||
* is an always-live segmented control (auto-saves).
|
||||
*/
|
||||
export default function CollectionPlaceDetail({
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, t,
|
||||
place, canEdit, canDelete, categories, labels, anchorRect, onClose, onSetStatus, onSave, onUploadImage, onCopyToTrip, onRemove, onRate, t,
|
||||
}: CollectionPlaceDetailProps): React.ReactElement {
|
||||
const toast = useToast()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const coverInputRef = useRef<HTMLInputElement>(null)
|
||||
const [imgBusy, setImgBusy] = useState(false)
|
||||
const [name, setName] = useState(place.name)
|
||||
const [categoryId, setCategoryId] = useState<number | null>(place.category_id ?? null)
|
||||
const [description, setDescription] = useState(place.description ?? '')
|
||||
const [links, setLinks] = useState<CollectionLink[]>(place.links ?? [])
|
||||
const [labelIds, setLabelIds] = useState<number[]>(place.label_ids ?? [])
|
||||
const [lat, setLat] = useState(place.lat != null ? String(place.lat) : '')
|
||||
const [lng, setLng] = useState(place.lng != null ? String(place.lng) : '')
|
||||
const [saving, setSaving] = useState(false)
|
||||
// A higher-res photo pulled from the maps provider when the place has none of
|
||||
// its own — the list avatar's little thumbnail is too low-res for the cover.
|
||||
@@ -81,6 +93,8 @@ export default function CollectionPlaceDetail({
|
||||
setDescription(place.description ?? '')
|
||||
setLinks(place.links ?? [])
|
||||
setLabelIds(place.label_ids ?? [])
|
||||
setLat(place.lat != null ? String(place.lat) : '')
|
||||
setLng(place.lng != null ? String(place.lng) : '')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [place.id])
|
||||
|
||||
@@ -99,16 +113,49 @@ export default function CollectionPlaceDetail({
|
||||
}, [place.id])
|
||||
|
||||
const banner = place.image_url || fetchedPhoto
|
||||
|
||||
const handleCoverPick = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (!file || !onUploadImage) return
|
||||
setImgBusy(true)
|
||||
try {
|
||||
await onUploadImage(await normalizeImageFile(file))
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
||||
} finally {
|
||||
setImgBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageRemove = async () => {
|
||||
setImgBusy(true)
|
||||
try {
|
||||
await onSave({ image_url: null })
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('places.imageUploadError')))
|
||||
} finally {
|
||||
setImgBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setLink = (i: number, patch: Partial<CollectionLink>) => setLinks(links.map((l, idx) => (idx === i ? { ...l, ...patch } : l)))
|
||||
const toggleLabel = (id: number) => setLabelIds(labelIds.includes(id) ? labelIds.filter(x => x !== id) : [...labelIds, id])
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []) }
|
||||
const resetForm = () => { setEditing(false); setName(place.name); setCategoryId(place.category_id ?? null); setDescription(place.description ?? ''); setLinks(place.links ?? []); setLabelIds(place.label_ids ?? []); setLat(place.lat != null ? String(place.lat) : ''); setLng(place.lng != null ? String(place.lng) : '') }
|
||||
const coordPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const text = e.clipboardData.getData('text').trim()
|
||||
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||
if (match) { e.preventDefault(); setLat(match[1]); setLng(match[2]) }
|
||||
}
|
||||
const assignedLabels = labels.filter(l => (place.label_ids ?? []).includes(l.id))
|
||||
|
||||
const save = async () => {
|
||||
const cleanLinks = links.map(l => ({ label: l.label?.trim() || undefined, url: normalizeLinkUrl(l.url) })).filter(l => l.url)
|
||||
const latNum = lat.trim() ? Number(lat) : NaN
|
||||
const lngNum = lng.trim() ? Number(lng) : NaN
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds })
|
||||
await onSave({ name: name.trim() || place.name, description: description.trim() || null, links: cleanLinks, category_id: categoryId, label_ids: labelIds, lat: Number.isFinite(latNum) ? latNum : null, lng: Number.isFinite(lngNum) ? lngNum : null })
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
@@ -131,6 +178,33 @@ export default function CollectionPlaceDetail({
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="col-detail-close" onClick={onClose} aria-label={t('common.close')}><X size={16} /></button>
|
||||
{canEdit && onUploadImage && (
|
||||
<div style={{ position: 'absolute', top: 10, left: 10, display: 'flex', gap: 6, zIndex: 2 }}>
|
||||
<Tooltip label={place.image_url ? t('places.changeImage') : t('places.uploadImage')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (!imgBusy) coverInputRef.current?.click() }}
|
||||
aria-label={place.image_url ? t('places.changeImage') : t('places.uploadImage')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: imgBusy ? 'default' : 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
{imgBusy ? <Loader2 size={15} className="animate-spin" /> : <Camera size={15} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{place.image_url && !imgBusy && (
|
||||
<Tooltip label={t('places.removeImage')} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageRemove}
|
||||
aria-label={t('places.removeImage')}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: 30, height: 30, borderRadius: 8, border: 'none', cursor: 'pointer', background: 'rgba(0,0,0,0.55)', color: '#fff', backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<input ref={coverInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp,.heic,.heif" style={{ display: 'none' }} onChange={handleCoverPick} />
|
||||
</div>
|
||||
)}
|
||||
<div className="col-detail-head">
|
||||
{editing
|
||||
? <input value={name} onChange={e => setName(e.target.value)} className="col-detail-name-input" autoFocus aria-label={t('collections.listName')} />
|
||||
@@ -149,6 +223,13 @@ export default function CollectionPlaceDetail({
|
||||
{/* Status — live for editors, read-only for viewers */}
|
||||
<StatusSegment status={place.status} onSet={canEdit ? onSetStatus : () => {}} t={t} />
|
||||
|
||||
{/* Collaborative rating (#1435) — every member votes; the average shows. */}
|
||||
{onRate && (
|
||||
<div style={{ padding: '2px 0' }}>
|
||||
<PlaceRating ratings={place.ratings ?? []} ratingAvg={place.rating_avg} onRate={onRate} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<div className="col-detail-edit">
|
||||
{/* Category */}
|
||||
@@ -167,6 +248,14 @@ export default function CollectionPlaceDetail({
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Coordinates */}
|
||||
<div className="col-detail-field">
|
||||
<div className="col-detail-label"><MapPin size={12} /> {t('collections.coordinates')}</div>
|
||||
<div className="col-detail-link-row">
|
||||
<NumericInput mode="signed" value={lat} onValueChange={setLat} onPaste={coordPaste} placeholder={t('places.formLat')} className="col-detail-input flex-1" />
|
||||
<NumericInput mode="signed" value={lng} onValueChange={setLng} placeholder={t('places.formLng')} className="col-detail-input flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Labels */}
|
||||
{labels.length > 0 && (
|
||||
<div className="col-detail-field">
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// FE-COMP-COPYTRIP-001 to FE-COMP-COPYTRIP-016
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { tripsApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import CopyToTripModal from './CopyToTripModal';
|
||||
|
||||
type ModalProps = Omit<React.ComponentProps<typeof CopyToTripModal>, 't'>;
|
||||
type CopyResult = { copied: number; skipped: { id: number; name: string }[] };
|
||||
|
||||
function Harness(props: ModalProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <CopyToTripModal {...props} t={t} />;
|
||||
}
|
||||
|
||||
const TRIPS = [
|
||||
{ id: 1, title: 'Rome 2026', start_date: '2026-04-02', end_date: '2026-04-09', cover_image: '/uploads/covers/rome.jpg' },
|
||||
{ id: 2, title: 'Tokyo', start_date: null, end_date: null, cover_image: null },
|
||||
];
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function renderModal(overrides: Partial<ModalProps> = {}) {
|
||||
const props: ModalProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
placeIds: [7],
|
||||
onCopy: vi.fn(async () => ({ copied: 1, skipped: [] })),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('CopyToTripModal', () => {
|
||||
it('FE-COMP-COPYTRIP-001: lists the user trips with their date range once loaded', async () => {
|
||||
renderModal();
|
||||
expect(await screen.findByText('Rome 2026')).toBeInTheDocument();
|
||||
const rome = screen.getByRole('button', { name: /Rome 2026/ });
|
||||
expect(within(rome).getByText(/Apr 2 – .*Apr 9/)).toBeInTheDocument();
|
||||
// A trip without dates renders no date row at all.
|
||||
const tokyo = screen.getByRole('button', { name: /Tokyo/ });
|
||||
expect(tokyo.querySelector('.lucide-calendar-days')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-015: a trips response landing after the modal closed is dropped', async () => {
|
||||
const closed = <Harness isOpen onClose={vi.fn()} placeIds={[7]} onCopy={vi.fn(async () => ({ copied: 0, skipped: [] }))} />;
|
||||
|
||||
let resolve!: (v: { trips: typeof TRIPS }) => void;
|
||||
const spy = vi.spyOn(tripsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
render(closed).unmount();
|
||||
resolve({ trips: TRIPS });
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('Rome 2026')).not.toBeInTheDocument();
|
||||
|
||||
// Same for a rejection — no "no trips" state is written into an unmounted tree.
|
||||
let reject!: (e: Error) => void;
|
||||
spy.mockReturnValue(new Promise((_, r) => { reject = r; }));
|
||||
render(closed).unmount();
|
||||
reject(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('No trips yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-016: a trip without a title still renders and stays filterable', async () => {
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: [{ id: 8, title: null }, ...TRIPS] });
|
||||
renderModal();
|
||||
|
||||
await screen.findByText('Rome 2026');
|
||||
fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'rome' } });
|
||||
expect(screen.getByText('Rome 2026')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: /Rome 2026|Tokyo/ })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-014: a one-sided date range falls back to the single date it has', async () => {
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({
|
||||
trips: [
|
||||
{ id: 3, title: 'Open ended', start_date: '2026-04-02', end_date: null },
|
||||
{ id: 4, title: 'Return only', start_date: null, end_date: '2026-04-09' },
|
||||
],
|
||||
});
|
||||
renderModal();
|
||||
await screen.findByText('Open ended');
|
||||
expect(within(screen.getByRole('button', { name: /Open ended/ })).getByText(/Apr 2/)).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('button', { name: /Return only/ })).getByText(/Apr 9/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-002: a cover image replaces the pin placeholder', async () => {
|
||||
renderModal();
|
||||
await screen.findByText('Rome 2026');
|
||||
const rome = screen.getByRole('button', { name: /Rome 2026/ });
|
||||
expect(rome.querySelector('img')).toHaveAttribute('src', '/uploads/covers/rome.jpg');
|
||||
expect(screen.getByRole('button', { name: /Tokyo/ }).querySelector('.lucide-map-pin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-003: the title is singular for one place and counted for a bulk copy', async () => {
|
||||
const { unmount } = render(<Harness {...{ isOpen: true, onClose: vi.fn(), placeIds: [7], onCopy: vi.fn(async () => ({ copied: 1, skipped: [] })) }} />);
|
||||
expect(await screen.findByRole('heading', { name: 'Copy to trip' })).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderModal({ placeIds: [7, 8, 9] });
|
||||
expect(await screen.findByRole('heading', { name: 'Copy 3 to trip' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-004: shows the spinner until the trips arrive', async () => {
|
||||
let resolve!: (v: { trips: typeof TRIPS }) => void;
|
||||
vi.spyOn(tripsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
renderModal();
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
||||
|
||||
resolve({ trips: TRIPS });
|
||||
expect(await screen.findByText('Rome 2026')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-005: an empty or failing trips response shows the no-trips copy', async () => {
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue({});
|
||||
const { unmount } = render(<Harness {...{ isOpen: true, onClose: vi.fn(), placeIds: [7], onCopy: vi.fn(async () => ({ copied: 0, skipped: [] })) }} />);
|
||||
expect(await screen.findByText('No trips yet')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
vi.spyOn(tripsApi, 'list').mockRejectedValue(new Error('offline'));
|
||||
renderModal();
|
||||
expect(await screen.findByText('No trips yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-006: requests nothing and renders nothing while closed', () => {
|
||||
const spy = vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS });
|
||||
renderModal({ isOpen: false });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('heading', { name: /Copy/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-007: the search box filters trips by title, case-insensitively', async () => {
|
||||
renderModal();
|
||||
const search = await screen.findByPlaceholderText('Search trips');
|
||||
|
||||
fireEvent.change(search, { target: { value: 'rome' } });
|
||||
expect(screen.getByText('Rome 2026')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tokyo')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: 'nope' } });
|
||||
expect(screen.getByText('No trips yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-008: picking a trip copies, reports the count and closes', async () => {
|
||||
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 2, skipped: [] }));
|
||||
const props = renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
expect(onCopy).toHaveBeenCalledWith(1);
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 2 places', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-009: server-side duplicates are reported alongside the copied count', async () => {
|
||||
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 1, skipped: [{ id: 9, name: 'Colosseum' }] }));
|
||||
renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-010: a no-op copy says so instead of staying silent', async () => {
|
||||
const onCopy = vi.fn(async (): Promise<CopyResult> => ({ copied: 0, skipped: [] }));
|
||||
renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-011: a failed copy surfaces the server message and keeps the modal open', async () => {
|
||||
const onCopy = vi.fn((): Promise<CopyResult> => Promise.reject({ response: { data: { error: 'Trip is locked' } } }));
|
||||
const props = renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Trip is locked', 'error', undefined));
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-012: a second click while a copy is running is ignored', async () => {
|
||||
let resolve!: (v: CopyResult) => void;
|
||||
const onCopy = vi.fn(() => new Promise<CopyResult>(r => { resolve = r; }));
|
||||
const props = renderModal({ onCopy });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
fireEvent.click(screen.getByText('Tokyo'));
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByRole('button', { name: /Rome 2026/ })).toBeDisabled();
|
||||
|
||||
resolve({ copied: 1, skipped: [] });
|
||||
await waitFor(() => expect(props.onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-COMP-COPYTRIP-013: an empty selection never reaches the copy handler and Escape closes', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderModal({ placeIds: [] });
|
||||
|
||||
fireEvent.click(await screen.findByText('Rome 2026'));
|
||||
expect(props.onCopy).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
// FE-COMP-LABELMGR-001 to FE-COMP-LABELMGR-013
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { CollectionLabel } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import LabelManager from './LabelManager';
|
||||
|
||||
type ManagerProps = Omit<React.ComponentProps<typeof LabelManager>, 't'>;
|
||||
|
||||
function Harness(props: ManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <LabelManager {...props} t={t} />;
|
||||
}
|
||||
|
||||
const berlin: CollectionLabel = { id: 1, collection_id: 10, name: 'Berlin', color: '#0ea5e9' };
|
||||
const food: CollectionLabel = { id: 2, collection_id: 10, name: 'Food', color: null };
|
||||
|
||||
function renderManager(overrides: Partial<ManagerProps> = {}) {
|
||||
const props: ManagerProps = {
|
||||
isOpen: true,
|
||||
labels: [berlin, food],
|
||||
onCreate: vi.fn(async () => {}),
|
||||
onUpdate: vi.fn(async () => {}),
|
||||
onDelete: vi.fn(async () => {}),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
/** The row whose rename input carries the given label name. */
|
||||
function row(name: string): HTMLElement {
|
||||
return screen.getByDisplayValue(name).closest('div') as HTMLElement;
|
||||
}
|
||||
|
||||
describe('LabelManager', () => {
|
||||
it('FE-COMP-LABELMGR-001: renders one editable row per label under the manage title', () => {
|
||||
renderManager();
|
||||
expect(screen.getByRole('heading', { name: 'Manage labels' })).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Berlin')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Food')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-002: shows the empty copy and no rows when the list has no labels', () => {
|
||||
renderManager({ labels: [] });
|
||||
expect(screen.getByText('No labels yet')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Label name')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-003: renders nothing at all while closed', () => {
|
||||
renderManager({ isOpen: false });
|
||||
expect(screen.queryByRole('heading', { name: 'Manage labels' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-004: the Add button stays disabled until a name is typed', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManager();
|
||||
const add = screen.getByRole('button', { name: /Add label/ });
|
||||
expect(add).toBeDisabled();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Nightlife');
|
||||
expect(add).toBeEnabled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-005: adding a label passes the trimmed name plus the picked colour and clears the form', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager();
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. Berlin'), ' Nightlife ');
|
||||
await user.click(screen.getByRole('button', { name: /Add label/ }));
|
||||
|
||||
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Nightlife', '#6366f1'));
|
||||
expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-006: picking a swatch in the create form changes the colour sent to onCreate', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager({ labels: [] });
|
||||
|
||||
// The only swatch row on screen belongs to the create form (no label rows).
|
||||
await user.click(screen.getByRole('button', { name: '#10b981' }));
|
||||
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Nature');
|
||||
await user.click(screen.getByRole('button', { name: /Add label/ }));
|
||||
|
||||
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Nature', '#10b981'));
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-007: Enter in the name field creates the label too', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager({ labels: [] });
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. Berlin'), 'Museums{Enter}');
|
||||
await waitFor(() => expect(props.onCreate).toHaveBeenCalledWith('Museums', '#6366f1'));
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-008: a whitespace-only name never reaches onCreate', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager({ labels: [] });
|
||||
|
||||
await user.type(screen.getByPlaceholderText('e.g. Berlin'), ' {Enter}');
|
||||
expect(props.onCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-009: a second submit while the first create is still pending is ignored', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCreate = vi.fn(() => new Promise<void>(() => {}));
|
||||
renderManager({ labels: [], onCreate });
|
||||
|
||||
const input = screen.getByPlaceholderText('e.g. Berlin');
|
||||
await user.type(input, 'Slow{Enter}');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
// The button shows the pending spinner while the promise is open.
|
||||
expect(screen.getByRole('button', { name: /Add label/ }).querySelector('.animate-spin')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-010: renaming a label commits the trimmed name on blur', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager();
|
||||
|
||||
const input = screen.getByDisplayValue('Berlin');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'Berlin Mitte');
|
||||
await user.tab();
|
||||
|
||||
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(1, { name: 'Berlin Mitte' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-011: Enter commits the rename, an unchanged or empty name reverts instead', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager();
|
||||
|
||||
const input = screen.getByDisplayValue('Food');
|
||||
await user.type(input, ' Trucks{Enter}');
|
||||
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(2, { name: 'Food Trucks' }));
|
||||
expect(props.onUpdate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Focus + blur without a change must not fire a second update.
|
||||
const berlinInput = screen.getByDisplayValue('Berlin');
|
||||
await user.click(berlinInput);
|
||||
await user.tab();
|
||||
expect(props.onUpdate).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Emptying the field restores the stored name rather than saving a blank one.
|
||||
await user.clear(berlinInput);
|
||||
await user.tab();
|
||||
expect(screen.getByDisplayValue('Berlin')).toBeInTheDocument();
|
||||
expect(props.onUpdate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-012: recolouring a row saves the new colour immediately', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager();
|
||||
|
||||
await user.click(within(row('Berlin')).getByRole('button', { name: '#ef4444' }));
|
||||
await waitFor(() => expect(props.onUpdate).toHaveBeenCalledWith(1, { color: '#ef4444' }));
|
||||
});
|
||||
|
||||
it('FE-COMP-LABELMGR-013: the row delete button passes the label id, the modal close hands back', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderManager();
|
||||
|
||||
await user.click(within(row('Food')).getByRole('button', { name: 'Delete' }));
|
||||
expect(props.onDelete).toHaveBeenCalledWith(2);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
// FE-COMP-LISTEDITOR-001 to FE-COMP-LISTEDITOR-025
|
||||
import React from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import { useCollectionStore } from '../../store/collectionStore'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import ListEditorModal from './ListEditorModal'
|
||||
|
||||
// The modal takes `t` as a prop, so a harness forwards the real English
|
||||
// translator from the provider the render helper mounts.
|
||||
type Props = React.ComponentProps<typeof ListEditorModal>
|
||||
function Harness(props: Omit<Props, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return <ListEditorModal {...props} t={t} />
|
||||
}
|
||||
|
||||
const existing: Collection = {
|
||||
id: 42,
|
||||
owner_id: 1,
|
||||
name: 'Tokyo 2026',
|
||||
color: '#ec4899',
|
||||
description: 'Ramen shortlist',
|
||||
links: [{ label: 'Guide', url: 'https://guide.example' }],
|
||||
cover_image: '/uploads/covers/tokyo.jpg',
|
||||
is_owner: true,
|
||||
}
|
||||
|
||||
type CollectionStore = ReturnType<typeof useCollectionStore.getState>
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
|
||||
const initialCollectionState = useCollectionStore.getState()
|
||||
let actions: {
|
||||
createCollection: Mock<CollectionStore['createCollection']>
|
||||
updateCollection: Mock<CollectionStore['updateCollection']>
|
||||
uploadCover: Mock<CollectionStore['uploadCover']>
|
||||
}
|
||||
let addToast: Mock<AddToast>
|
||||
|
||||
const realCreateObjectURL = URL.createObjectURL
|
||||
const realRevokeObjectURL = URL.revokeObjectURL
|
||||
function stubObjectUrl(value: unknown, revoke: unknown): void {
|
||||
Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: revoke })
|
||||
}
|
||||
|
||||
function setup(over: Partial<Omit<Props, 't'>> = {}) {
|
||||
const props: Omit<Props, 't'> = {
|
||||
target: 'new',
|
||||
onClose: vi.fn(),
|
||||
onCreated: vi.fn(),
|
||||
onRequestDelete: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, props }
|
||||
}
|
||||
|
||||
describe('ListEditorModal', () => {
|
||||
beforeEach(() => {
|
||||
useCollectionStore.setState(initialCollectionState, true)
|
||||
actions = {
|
||||
createCollection: vi.fn<CollectionStore['createCollection']>(async () => ({ id: 77, owner_id: 1, name: 'Fresh' })),
|
||||
updateCollection: vi.fn<CollectionStore['updateCollection']>(async () => undefined),
|
||||
uploadCover: vi.fn<CollectionStore['uploadCover']>(async () => undefined),
|
||||
}
|
||||
useCollectionStore.setState(actions)
|
||||
addToast = vi.fn<AddToast>(() => 0)
|
||||
window.__addToast = addToast
|
||||
// Node's URL.createObjectURL rejects a jsdom File, so the preview blob is stubbed.
|
||||
stubObjectUrl(vi.fn(() => 'blob:cover-preview'), vi.fn())
|
||||
vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({
|
||||
photos: [
|
||||
{ id: 'p1', url: 'https://img/full1.jpg', thumb: 'https://img/thumb1.jpg', description: 'A street', photographer: 'Rin' },
|
||||
{ id: 'p2', url: 'https://img/full2.jpg', thumb: 'https://img/thumb2.jpg', description: null, photographer: null },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
stubObjectUrl(realCreateObjectURL, realRevokeObjectURL)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-001: renders nothing while the target is null', () => {
|
||||
setup({ target: null })
|
||||
expect(screen.queryByRole('heading', { name: /New list|Edit list/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-002: create mode shows the New list title and a Create action', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('heading', { name: 'New list' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /Delete list/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-003: edit mode seeds name, description, links and the cover preview', () => {
|
||||
setup({ target: existing })
|
||||
expect(screen.getByRole('heading', { name: 'Edit list' })).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Tokyo 2026')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Ramen shortlist')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('Guide')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('https://guide.example')).toBeInTheDocument()
|
||||
// A seeded cover flips the button label and paints the image.
|
||||
expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument()
|
||||
expect(document.querySelector('img[src="/uploads/covers/tokyo.jpg"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-004: the owner gets a Delete list action that closes and hands off the id', () => {
|
||||
const { props } = setup({ target: existing })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Delete list/ }))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(props.onRequestDelete).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-005: a non-owner editor sees no Delete list action', () => {
|
||||
setup({ target: { ...existing, is_owner: false } })
|
||||
expect(screen.queryByRole('button', { name: /Delete list/ })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-006: creating posts the trimmed payload and reports the new id', async () => {
|
||||
const { props } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: ' Rome ' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Add a description…'), { target: { value: ' gelato ' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalledTimes(1))
|
||||
expect(actions.createCollection).toHaveBeenCalledWith({
|
||||
name: 'Rome',
|
||||
color: '#6366f1',
|
||||
description: 'gelato',
|
||||
links: [],
|
||||
})
|
||||
expect(props.onCreated).toHaveBeenCalledWith(77)
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-007: an empty description is sent as null', async () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Rome' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
expect(actions.createCollection.mock.calls[0][0].description).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-008: editing patches the existing list and never calls onCreated', async () => {
|
||||
const { props } = setup({ target: existing })
|
||||
fireEvent.change(screen.getByDisplayValue('Tokyo 2026'), { target: { value: 'Tokyo 2027' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
await waitFor(() => expect(actions.updateCollection).toHaveBeenCalledTimes(1))
|
||||
expect(actions.updateCollection).toHaveBeenCalledWith(42, expect.objectContaining({ name: 'Tokyo 2027', color: '#ec4899' }))
|
||||
expect(actions.createCollection).not.toHaveBeenCalled()
|
||||
expect(props.onCreated).not.toHaveBeenCalled()
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-009: Enter in the name field saves', async () => {
|
||||
setup()
|
||||
const nameInput = screen.getByPlaceholderText('e.g. Tokyo 2025')
|
||||
fireEvent.change(nameInput, { target: { value: 'Lisbon' } })
|
||||
fireEvent.keyDown(nameInput, { key: 'Enter' })
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-010: Enter on a blank name does nothing', () => {
|
||||
setup()
|
||||
fireEvent.keyDown(screen.getByPlaceholderText('e.g. Tokyo 2025'), { key: 'Enter' })
|
||||
expect(actions.createCollection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-011: picking a swatch changes the saved colour', async () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '#22c55e' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
expect(actions.createCollection.mock.calls[0][0].color).toBe('#22c55e')
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-012: links are normalised, labelled and blank rows are dropped', async () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add link/ }))
|
||||
|
||||
const urls = screen.getAllByPlaceholderText('https://…')
|
||||
const labels = screen.getAllByPlaceholderText('Label')
|
||||
fireEvent.change(labels[0], { target: { value: ' Menu ' } })
|
||||
fireEvent.change(urls[0], { target: { value: 'example.com/menu' } })
|
||||
// The second row stays blank and must not reach the payload.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
expect(actions.createCollection.mock.calls[0][0].links).toEqual([
|
||||
{ label: 'Menu', url: 'https://example.com/menu' },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-013: a link row can be removed again', () => {
|
||||
setup({ target: existing })
|
||||
expect(screen.getByDisplayValue('https://guide.example')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0])
|
||||
expect(screen.queryByDisplayValue('https://guide.example')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-014: choosing a file shows a preview and uploads it after the create', async () => {
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' })
|
||||
const { container } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument()
|
||||
expect(document.querySelector('img[src="blob:cover-preview"]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(actions.uploadCover).toHaveBeenCalledWith(77, file))
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-015: the cover tile opens the file picker, and an empty pick leaves the cover alone', () => {
|
||||
const { container } = setup()
|
||||
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
||||
const openPicker = vi.fn()
|
||||
fileInput.addEventListener('click', openPicker)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add cover' }))
|
||||
expect(openPicker).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.change(fileInput, { target: { files: [] } })
|
||||
expect(screen.getByRole('button', { name: 'Add cover' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-016: the Unsplash search renders results and the picked photo becomes cover_image', async () => {
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Rin' })).toBeInTheDocument()
|
||||
expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('fjord')
|
||||
// A photo without a photographer falls back to the generic label.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Unsplash' }))
|
||||
expect(document.querySelector('img[src="https://img/full2.jpg"]')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
expect(actions.createCollection.mock.calls[0][0].cover_image).toBe('https://img/full2.jpg')
|
||||
expect(actions.uploadCover).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-017: an empty query falls back to the list name, Enter triggers the search', async () => {
|
||||
setup()
|
||||
const query = screen.getByPlaceholderText('Search destination photos')
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Bergen' } })
|
||||
fireEvent.keyDown(query, { key: 'Enter' })
|
||||
await waitFor(() => expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('Bergen'))
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-018: with neither a query nor a name the search button stays disabled', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('button', { name: /Search Unsplash/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-019: a failing cover search clears the result grid', async () => {
|
||||
vi.mocked(tripsApi.searchCoverImages).mockRejectedValueOnce(new Error('offline'))
|
||||
setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
|
||||
await waitFor(() => expect(tripsApi.searchCoverImages).toHaveBeenCalled())
|
||||
expect(screen.queryByRole('button', { name: 'Rin' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-020: an uploaded file wins over a previously picked Unsplash photo', async () => {
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' })
|
||||
const { container } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'fjord' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /Search Unsplash/ }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Rin' }))
|
||||
|
||||
const fileInput = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement
|
||||
fireEvent.change(fileInput, { target: { files: [file] } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
|
||||
await waitFor(() => expect(actions.createCollection).toHaveBeenCalled())
|
||||
expect(actions.createCollection.mock.calls[0][0].cover_image).toBeUndefined()
|
||||
await waitFor(() => expect(actions.uploadCover).toHaveBeenCalledWith(77, file))
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-021: a failed cover upload toasts the server message and the retry updates instead of re-creating', async () => {
|
||||
actions.uploadCover.mockRejectedValueOnce({ response: { data: { error: 'Cover too large' } } })
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' })
|
||||
const { container, props } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.change(container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Cover too large', 'error', undefined))
|
||||
expect(props.onClose).not.toHaveBeenCalled()
|
||||
|
||||
// Retry: the id from the first (successful) create is reused.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
await waitFor(() => expect(actions.updateCollection).toHaveBeenCalledWith(77, expect.objectContaining({ name: 'Oslo' })))
|
||||
expect(actions.createCollection).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-022: switching the target reseeds the form', () => {
|
||||
const { rerender, props } = setup({ target: existing })
|
||||
fireEvent.change(screen.getByDisplayValue('Tokyo 2026'), { target: { value: 'scratch' } })
|
||||
rerender(<Harness {...props} target="new" />)
|
||||
expect(screen.queryByDisplayValue('scratch')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'New list' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Add cover' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-023: a create that fails outright keeps the modal open and reports the error', async () => {
|
||||
actions.createCollection.mockRejectedValueOnce(new Error('boom'))
|
||||
const { props } = setup()
|
||||
fireEvent.change(screen.getByPlaceholderText('e.g. Tokyo 2025'), { target: { value: 'Oslo' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
// A plain failure has no server-provided message, so the translated fallback shows.
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
|
||||
expect(props.onClose).not.toHaveBeenCalled()
|
||||
// The save button releases again so the user can retry.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Create' })).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-024: unmounting revokes the last preview blob', () => {
|
||||
const revoke = vi.fn()
|
||||
stubObjectUrl(vi.fn(() => 'blob:cover-preview'), revoke)
|
||||
const file = new File(['x'], 'cover.png', { type: 'image/png' })
|
||||
const { container, unmount } = setup()
|
||||
fireEvent.change(container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
|
||||
unmount()
|
||||
expect(revoke).toHaveBeenCalledWith('blob:cover-preview')
|
||||
})
|
||||
|
||||
it('FE-COMP-LISTEDITOR-025: Cancel closes without touching the store', () => {
|
||||
const { props } = setup({ target: existing })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
expect(actions.updateCollection).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import Modal from '../shared/Modal'
|
||||
import { useCollectionStore } from '../../store/collectionStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { normalizeLinkUrl } from '../../pages/collections/collectionsModel'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { Collection, CollectionLink } from '@trek/shared'
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// FE-COMP-LISTSRAIL-001 to FE-COMP-LISTSRAIL-010
|
||||
import React from 'react';
|
||||
import { render, screen, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Collection } from '@trek/shared';
|
||||
import { useTranslation } from '../../i18n/TranslationContext';
|
||||
import { ALL_SAVED } from '../../store/collectionStore';
|
||||
import type { IncomingCollectionInvite } from '../../store/collectionStore';
|
||||
import ListsRail from './ListsRail';
|
||||
|
||||
// The rail takes `t` as a prop, so a harness forwards the real English translator
|
||||
// from the provider the render helper mounts.
|
||||
type RailProps = Omit<React.ComponentProps<typeof ListsRail>, 't'>;
|
||||
|
||||
function Harness(props: RailProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return <ListsRail {...props} t={t} />;
|
||||
}
|
||||
|
||||
const rome: Collection = { id: 11, owner_id: 1, name: 'Weekend in Rome', color: '#ef4444', place_count: 3 };
|
||||
const tokyo: Collection = { id: 22, owner_id: 1, name: 'Tokyo Food Tour', color: null };
|
||||
const shared: Collection = { id: 33, owner_id: 9, name: 'Family trip', color: '#22c55e', place_count: 7, is_owner: false };
|
||||
|
||||
const invite: IncomingCollectionInvite = {
|
||||
collection_id: 44,
|
||||
name: 'Iceland ideas',
|
||||
from: { id: 5, username: 'kim' },
|
||||
};
|
||||
|
||||
function renderRail(overrides: Partial<RailProps> = {}) {
|
||||
const props: RailProps = {
|
||||
ownedLists: [rome, tokyo],
|
||||
sharedLists: [],
|
||||
activeId: rome.id,
|
||||
incomingInvites: [],
|
||||
onSelect: vi.fn(),
|
||||
onNewList: vi.fn(),
|
||||
onAcceptInvite: vi.fn(),
|
||||
onDeclineInvite: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<Harness {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('ListsRail', () => {
|
||||
it('FE-COMP-LISTSRAIL-001: renders the new-list action, the All saved row and every owned list', () => {
|
||||
renderRail();
|
||||
expect(screen.getByRole('button', { name: 'New list' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Tokyo Food Tour/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-002: shows the place count per list and falls back to 0 when absent', () => {
|
||||
renderRail();
|
||||
expect(within(screen.getByRole('button', { name: /Weekend in Rome/ })).getByText('3')).toBeInTheDocument();
|
||||
// tokyo carries no place_count — the rail renders 0 rather than an empty slot.
|
||||
expect(within(screen.getByRole('button', { name: /Tokyo Food Tour/ })).getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-003: marks only the active list row with the "on" class', () => {
|
||||
renderRail({ activeId: tokyo.id });
|
||||
expect(screen.getByRole('button', { name: /Tokyo Food Tour/ })).toHaveClass('on');
|
||||
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).not.toHaveClass('on');
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).not.toHaveClass('on');
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-004: marks the All saved row active when activeId is the sentinel', () => {
|
||||
renderRail({ activeId: ALL_SAVED });
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).toHaveClass('on');
|
||||
expect(screen.getByRole('button', { name: /Weekend in Rome/ })).not.toHaveClass('on');
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-005: clicking a list row selects it by id, All saved selects the sentinel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderRail();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Tokyo Food Tour/ }));
|
||||
expect(props.onSelect).toHaveBeenCalledWith(22);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'All saved' }));
|
||||
expect(props.onSelect).toHaveBeenCalledWith(ALL_SAVED);
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-006: the new-list button calls onNewList', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderRail();
|
||||
await user.click(screen.getByRole('button', { name: 'New list' }));
|
||||
expect(props.onNewList).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-007: renders a Shared section with its lists only when there are shared lists', () => {
|
||||
const { unmount } = render(<Harness {...{
|
||||
ownedLists: [rome], sharedLists: [], activeId: null, incomingInvites: [],
|
||||
onSelect: vi.fn(), onNewList: vi.fn(), onAcceptInvite: vi.fn(), onDeclineInvite: vi.fn(),
|
||||
}} />);
|
||||
expect(screen.queryByText('Shared')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
renderRail({ sharedLists: [shared] });
|
||||
expect(screen.getByText('Shared')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Family trip/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-008: renders the invites block with the sender name and a count badge', () => {
|
||||
renderRail({ incomingInvites: [invite] });
|
||||
expect(screen.getByText('Iceland ideas')).toBeInTheDocument();
|
||||
expect(screen.getByText('from kim')).toBeInTheDocument();
|
||||
expect(screen.getByText('Invites').querySelector('.badge')?.textContent).toBe('1');
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-009: accepting and declining an invite pass the collection id', async () => {
|
||||
const user = userEvent.setup();
|
||||
const props = renderRail({ incomingInvites: [invite] });
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Accept' }));
|
||||
expect(props.onAcceptInvite).toHaveBeenCalledWith(44);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Decline' }));
|
||||
expect(props.onDeclineInvite).toHaveBeenCalledWith(44);
|
||||
});
|
||||
|
||||
it('FE-COMP-LISTSRAIL-010: with no owned lists, no separator and no invite block render', () => {
|
||||
renderRail({ ownedLists: [], activeId: ALL_SAVED });
|
||||
expect(document.querySelector('.col-rail-sep')).toBeNull();
|
||||
expect(screen.queryByText('Invites')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'All saved' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,266 @@
|
||||
// FE-COMP-MSAVESHEET-001 to FE-COMP-MSAVESHEET-016
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import type { Collection, CollectionListResponse, CollectionMembership, CollectionSaveResult } from '@trek/shared';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore';
|
||||
import MSaveToCollectionSheet from './MSaveToCollectionSheet';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function list(over: Partial<Collection>): Collection {
|
||||
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
|
||||
}
|
||||
|
||||
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
|
||||
const WISHLIST = list({ id: 2, name: 'Wishlist' });
|
||||
const SHARED = list({ id: 3, name: 'Team ideas', is_owner: false });
|
||||
|
||||
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
|
||||
const EMPTY_MEMBERSHIP: CollectionMembership = { saved: false, lists: [] };
|
||||
|
||||
const TARGET: SaveToCollectionTarget = {
|
||||
name: 'Colosseum',
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
};
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function openFor(target: SaveToCollectionTarget = TARGET) {
|
||||
useSaveToCollectionStore.setState({ target, version: 0 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
mockNavigate.mockClear();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(EMPTY_MEMBERSHIP);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('MSaveToCollectionSheet', () => {
|
||||
it('FE-COMP-MSAVESHEET-001: stays closed and silent while no target is set', () => {
|
||||
render(<MSaveToCollectionSheet />);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(collectionsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-002: opens a labelled sheet showing the target name and the lists', async () => {
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Save to list' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Colosseum')).toBeInTheDocument();
|
||||
expect(screen.getByText('4 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-003: queries membership with the maps identity of the target', async () => {
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
await screen.findByText('Favorites');
|
||||
expect(collectionsApi.membership).toHaveBeenCalledWith({
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
name: 'Colosseum',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-004: shows the spinner until the lists arrive', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
||||
|
||||
resolve(listResponse([FAVORITES]));
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-005: a failing list request shows the empty state and its create shortcut', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
expect(await screen.findByText('Create a list first to save places.')).toBeInTheDocument();
|
||||
// With no lists the sheet drops its footer bar entirely.
|
||||
expect(screen.queryByRole('button', { name: 'View' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /New list/ }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections');
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
||||
expect(favorites.querySelector('.lucide-bookmark-check')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: /Wishlist/ }).querySelector('.lucide-bookmark-check')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-007: a failing membership lookup degrades to "saved nowhere"', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline'));
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
||||
expect(favorites.querySelector('.lucide-bookmark-check')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-008: a shared list carries the SHARED badge', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, SHARED]));
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
const shared = await screen.findByRole('button', { name: /Team ideas/ });
|
||||
expect(within(shared).getByText('Shared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-009: tapping an unsaved list saves the target and bumps the version', async () => {
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Added to Wishlist', 'success', undefined));
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
collection_id: 2,
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
name: 'Colosseum',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
address: null,
|
||||
force: true,
|
||||
}));
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-010: tapping a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Favorites/ }));
|
||||
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(900));
|
||||
expect(addToast).toHaveBeenCalledWith('Removed from Favorites', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-011: a failing save surfaces the server message and leaves the version alone', async () => {
|
||||
vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue({ response: { data: { error: 'List is full' } } });
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-012: every row locks while one save runs, so a second tap is dropped', async () => {
|
||||
let resolve!: (v: CollectionSaveResult) => void;
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
|
||||
fireEvent.click(wishlist);
|
||||
const favorites = screen.getByRole('button', { name: /Favorites/ });
|
||||
expect(wishlist).toBeDisabled();
|
||||
expect(favorites).toBeDisabled();
|
||||
fireEvent.click(favorites);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({});
|
||||
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-015: a membership refresh that fails after a save falls back to "saved nowhere"', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership')
|
||||
.mockResolvedValueOnce(EMPTY_MEMBERSHIP)
|
||||
.mockRejectedValue(new Error('offline'));
|
||||
vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
|
||||
expect(screen.getByRole('button', { name: /Wishlist/ }).querySelector('.lucide-bookmark-check')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-016: a response landing after the sheet closed is dropped', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
const { unmount } = render(<MSaveToCollectionSheet />);
|
||||
unmount();
|
||||
|
||||
resolve(listResponse([FAVORITES]));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-013: the footer View navigates to the collections page', async () => {
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections');
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-MSAVESHEET-014: both the header X and the footer Close dismiss without navigating', async () => {
|
||||
openFor();
|
||||
const { unmount } = render(<MSaveToCollectionSheet />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
// Header icon button first in the DOM, footer button last.
|
||||
const closes = screen.getAllByRole('button', { name: 'Close' });
|
||||
expect(closes).toHaveLength(2);
|
||||
fireEvent.click(closes[closes.length - 1]);
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
unmount();
|
||||
|
||||
openFor();
|
||||
render(<MSaveToCollectionSheet />);
|
||||
await screen.findByText('Favorites');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Close' })[0]);
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Bookmark, BookmarkCheck, Check, Loader2, Plus, X } from 'lucide-react'
|
||||
import MSheet from '../../mobile/components/MSheet'
|
||||
import MIconBtn from '../../mobile/components/MIconBtn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import type { Collection, CollectionMembership } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Mobile counterpart of SaveToCollectionModal — the same store-driven list
|
||||
* picker (load lists + membership, toggle the place in/out of each), dressed in
|
||||
* the mobile design language (MSheet card, m-* tokens) so it matches the place
|
||||
* detail sheet. Rendered instead of the desktop modal on phones (see App.tsx).
|
||||
*/
|
||||
export default function MSaveToCollectionSheet() {
|
||||
const target = useSaveToCollectionStore(s => s.target)
|
||||
const close = useSaveToCollectionStore(s => s.close)
|
||||
const bumpVersion = useSaveToCollectionStore(s => s.bumpVersion)
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [lists, setLists] = useState<Collection[]>([])
|
||||
const [membership, setMembership] = useState<CollectionMembership | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
|
||||
const membershipQuery = useMemo(() => {
|
||||
if (!target) return null
|
||||
return {
|
||||
google_place_id: target.google_place_id ?? undefined,
|
||||
google_ftid: target.google_ftid ?? undefined,
|
||||
name: target.name,
|
||||
lat: target.lat ?? undefined,
|
||||
lng: target.lng ?? undefined,
|
||||
}
|
||||
}, [target])
|
||||
|
||||
const refreshMembership = useCallback(async () => {
|
||||
if (!membershipQuery) return
|
||||
try {
|
||||
setMembership(await collectionsApi.membership(membershipQuery))
|
||||
} catch {
|
||||
setMembership({ saved: false, lists: [] })
|
||||
}
|
||||
}, [membershipQuery])
|
||||
|
||||
// Load lists + membership whenever the picker opens for a new target.
|
||||
useEffect(() => {
|
||||
if (!target) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setMembership(null)
|
||||
Promise.all([
|
||||
collectionsApi.list().catch(() => ({ collections: [], incomingInvites: [] })),
|
||||
membershipQuery
|
||||
? collectionsApi.membership(membershipQuery).catch(() => ({ saved: false, lists: [] as CollectionMembership['lists'] }))
|
||||
: Promise.resolve({ saved: false, lists: [] as CollectionMembership['lists'] }),
|
||||
])
|
||||
.then(([listRes, m]) => {
|
||||
if (cancelled) return
|
||||
setLists(listRes.collections)
|
||||
setMembership(m)
|
||||
})
|
||||
.finally(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [target])
|
||||
|
||||
const savedByCollection = new Map<number, number>()
|
||||
for (const l of membership?.lists ?? []) savedByCollection.set(l.collection_id, l.place_id)
|
||||
|
||||
const handleToggle = async (list: Collection) => {
|
||||
if (busyId != null || !target) return
|
||||
const savedPlaceId = savedByCollection.get(list.id)
|
||||
setBusyId(list.id)
|
||||
try {
|
||||
if (savedPlaceId != null) {
|
||||
await collectionsApi.deletePlace(savedPlaceId)
|
||||
toast.success(t('collections.removedFromList', { name: list.name }))
|
||||
} else {
|
||||
await collectionsApi.savePlace({
|
||||
collection_id: list.id,
|
||||
source_trip_id: target.source_trip_id ?? null,
|
||||
source_place_id: target.source_place_id ?? null,
|
||||
name: target.name,
|
||||
description: target.description ?? null,
|
||||
lat: target.lat ?? null,
|
||||
lng: target.lng ?? null,
|
||||
address: target.address ?? null,
|
||||
category_id: target.category_id ?? null,
|
||||
price: target.price ?? null,
|
||||
currency: target.currency ?? null,
|
||||
notes: target.notes ?? null,
|
||||
image_url: target.image_url ?? null,
|
||||
google_place_id: target.google_place_id ?? null,
|
||||
google_ftid: target.google_ftid ?? null,
|
||||
osm_id: target.osm_id ?? null,
|
||||
website: target.website ?? null,
|
||||
phone: target.phone ?? null,
|
||||
force: true,
|
||||
})
|
||||
toast.success(t('collections.addedToList', { name: list.name }))
|
||||
}
|
||||
await refreshMembership()
|
||||
bumpVersion()
|
||||
} catch (err) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={!!target} onClose={close} variant="card" material="glass" ariaLabel={t('collections.pickList')}>
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[1rem] font-bold leading-snug">{t('collections.pickList')}</div>
|
||||
{target?.name && (
|
||||
<div className="mt-[2px] truncate font-geist text-[0.6875rem] text-m-muted">{target.name}</div>
|
||||
)}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-[16px] pt-2">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-m-faint">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : lists.length === 0 ? (
|
||||
<div className="flex flex-col items-center px-4 py-10 text-center">
|
||||
<span className="mb-3 flex h-11 w-11 items-center justify-center rounded-2xl bg-[color:var(--m-ic)] text-m-faint">
|
||||
<Bookmark size={20} strokeWidth={2} />
|
||||
</span>
|
||||
<p className="mb-3 font-geist text-[0.75rem] text-m-faint">{t('collections.noListsYet')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-m-act px-4 py-[9px] text-[0.75rem] font-semibold text-m-actfg"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
lists.map(list => {
|
||||
const saved = savedByCollection.has(list.id)
|
||||
const busy = busyId === list.id
|
||||
return (
|
||||
<button
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busyId != null}
|
||||
className={`mt-2 flex w-full items-center gap-[11px] rounded-[14px] border px-3 py-[10px] text-left disabled:opacity-60 ${
|
||||
saved
|
||||
? 'border-[color:var(--m-act)] bg-[color:var(--m-inner)]'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 flex-none items-center justify-center rounded-xl text-white"
|
||||
style={{ background: list.color || '#6366f1' }}
|
||||
>
|
||||
<Bookmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[0.8125rem] font-semibold text-m-ink">{list.name}</span>
|
||||
<span className="mt-px block font-geist text-[0.65625rem] text-m-muted">
|
||||
{t('collections.placeCount', { count: list.place_count ?? 0 })}
|
||||
</span>
|
||||
</span>
|
||||
{list.is_owner === false && (
|
||||
<span className="flex-none font-geist text-[0.5625rem] font-bold uppercase tracking-[.05em] text-m-faint">
|
||||
{t('collections.shared')}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full ${
|
||||
saved ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] text-m-faint'
|
||||
}`}
|
||||
>
|
||||
{busy ? <Loader2 size={14} className="animate-spin" /> : saved ? <BookmarkCheck size={14} strokeWidth={2} /> : <Check size={14} strokeWidth={2} />}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<div className="flex flex-none items-center justify-between gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { close(); navigate('/collections') }}
|
||||
className="text-[0.78125rem] font-semibold text-[color:var(--m-act)]"
|
||||
>
|
||||
{t('collections.viewInCollection')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="rounded-full bg-[color:var(--m-ic)] px-4 py-[8px] text-[0.78125rem] font-semibold text-m-ink"
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</MSheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
// FE-COMP-SAVETOCOL-001 to FE-COMP-SAVETOCOL-016
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import type { Collection, CollectionListResponse, CollectionMembership, CollectionSaveResult } from '@trek/shared';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore';
|
||||
import type { SaveToCollectionTarget } from '../../store/saveToCollectionStore';
|
||||
import SaveToCollectionModal from './SaveToCollectionModal';
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return { ...actual, useNavigate: () => mockNavigate };
|
||||
});
|
||||
|
||||
function list(over: Partial<Collection>): Collection {
|
||||
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
|
||||
}
|
||||
|
||||
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
|
||||
const WISHLIST = list({ id: 2, name: 'Wishlist' });
|
||||
const SHARED = list({ id: 3, name: 'Team ideas', is_owner: false });
|
||||
|
||||
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
|
||||
const EMPTY_MEMBERSHIP: CollectionMembership = { saved: false, lists: [] };
|
||||
|
||||
const TARGET: SaveToCollectionTarget = {
|
||||
name: 'Colosseum',
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
description: 'Ancient arena',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
address: 'Rome',
|
||||
category_id: 3,
|
||||
price: 16,
|
||||
currency: 'EUR',
|
||||
notes: 'Book ahead',
|
||||
image_url: '/uploads/places/colosseum.jpg',
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
osm_id: 'osm-1',
|
||||
website: 'https://colosseo.example',
|
||||
phone: '+39',
|
||||
};
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function openFor(target: SaveToCollectionTarget = TARGET) {
|
||||
useSaveToCollectionStore.setState({ target, version: 0 });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
mockNavigate.mockClear();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue(EMPTY_MEMBERSHIP);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useSaveToCollectionStore.setState({ target: null, version: 0 });
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('SaveToCollectionModal', () => {
|
||||
it('FE-COMP-SAVETOCOL-001: renders nothing and calls no api while no target is set', () => {
|
||||
render(<SaveToCollectionModal />);
|
||||
expect(screen.queryByRole('heading', { name: 'Save to list' })).not.toBeInTheDocument();
|
||||
expect(collectionsApi.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-002: shows the target name plus every list once loaded', async () => {
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Save to list' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Colosseum')).toBeInTheDocument();
|
||||
expect(screen.getByText('Wishlist')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-003: queries membership with the maps identity of the target', async () => {
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
await screen.findByText('Favorites');
|
||||
expect(collectionsApi.membership).toHaveBeenCalledWith({
|
||||
google_place_id: 'gp-1',
|
||||
google_ftid: 'ft-1',
|
||||
name: 'Colosseum',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-004: shows the spinner until the lists arrive', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
||||
|
||||
resolve(listResponse([FAVORITES]));
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-005: a failing list request falls back to the empty state with a create shortcut', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
expect(await screen.findByText('Create a list first to save places.')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: /New list/ }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections');
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-006: lists already holding the place are marked saved', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
||||
expect(favorites).toHaveClass('border-accent');
|
||||
expect(favorites.querySelector('.lucide-bookmark-check')).not.toBeNull();
|
||||
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-007: a failing membership lookup degrades to "saved nowhere"', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockRejectedValue(new Error('offline'));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
||||
expect(favorites).not.toHaveClass('border-accent');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-008: a shared list carries the SHARED badge', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, SHARED]));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const shared = await screen.findByRole('button', { name: /Team ideas/ });
|
||||
expect(within(shared).getByText('Shared')).toBeInTheDocument();
|
||||
expect(within(screen.getByRole('button', { name: /Favorites/ })).queryByText('Shared')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-009: picking an unsaved list forwards the whole target payload and bumps the version', async () => {
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Added to Wishlist', 'success', undefined));
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
collection_id: 2,
|
||||
source_trip_id: 5,
|
||||
source_place_id: 42,
|
||||
name: 'Colosseum',
|
||||
lat: 41.89,
|
||||
lng: 12.49,
|
||||
google_place_id: 'gp-1',
|
||||
osm_id: 'osm-1',
|
||||
website: 'https://colosseo.example',
|
||||
force: true,
|
||||
}));
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-010: a sparse target sends explicit nulls rather than undefined', async () => {
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
openFor({ name: 'Nameless bar' });
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(save).toHaveBeenCalled());
|
||||
expect(save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
collection_id: 2,
|
||||
name: 'Nameless bar',
|
||||
lat: null,
|
||||
lng: null,
|
||||
source_trip_id: null,
|
||||
phone: null,
|
||||
}));
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-011: picking a saved list removes that place instead', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership').mockResolvedValue({
|
||||
saved: true,
|
||||
lists: [{ collection_id: 1, name: 'Favorites', place_id: 900 }],
|
||||
});
|
||||
const del = vi.spyOn(collectionsApi, 'deletePlace').mockResolvedValue({});
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace');
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Favorites/ }));
|
||||
|
||||
await waitFor(() => expect(del).toHaveBeenCalledWith(900));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
expect(addToast).toHaveBeenCalledWith('Removed from Favorites', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-012: a failing save surfaces the server message and leaves the version alone', async () => {
|
||||
vi.spyOn(collectionsApi, 'savePlace').mockRejectedValue({ response: { data: { error: 'List is full' } } });
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
|
||||
expect(useSaveToCollectionStore.getState().version).toBe(0);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-013: every row locks while a save runs', async () => {
|
||||
let resolve!: (v: CollectionSaveResult) => void;
|
||||
const save = vi.spyOn(collectionsApi, 'savePlace').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
const wishlist = await screen.findByRole('button', { name: /Wishlist/ });
|
||||
fireEvent.click(wishlist);
|
||||
expect(wishlist).toBeDisabled();
|
||||
const favorites = screen.getByRole('button', { name: /Favorites/ });
|
||||
expect(favorites).toBeDisabled();
|
||||
fireEvent.click(favorites);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({});
|
||||
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-015: a membership refresh that fails after a save falls back to "saved nowhere"', async () => {
|
||||
vi.spyOn(collectionsApi, 'membership')
|
||||
.mockResolvedValueOnce(EMPTY_MEMBERSHIP)
|
||||
.mockRejectedValue(new Error('offline'));
|
||||
vi.spyOn(collectionsApi, 'savePlace').mockResolvedValue({});
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Wishlist/ }));
|
||||
|
||||
await waitFor(() => expect(useSaveToCollectionStore.getState().version).toBe(1));
|
||||
expect(screen.getByRole('button', { name: /Wishlist/ })).not.toHaveClass('border-accent');
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-016: a response landing after the picker closed is dropped', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
openFor();
|
||||
const { unmount } = render(<SaveToCollectionModal />);
|
||||
unmount();
|
||||
|
||||
resolve(listResponse([FAVORITES]));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETOCOL-014: the footer navigates to the collections page or just closes', async () => {
|
||||
openFor();
|
||||
const { unmount } = render(<SaveToCollectionModal />);
|
||||
await screen.findByText('Favorites');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'View' }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/collections');
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
unmount();
|
||||
|
||||
mockNavigate.mockClear();
|
||||
openFor();
|
||||
render(<SaveToCollectionModal />);
|
||||
await screen.findByText('Favorites');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
expect(useSaveToCollectionStore.getState().target).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -169,7 +169,7 @@ export default function SaveToCollectionModal(): React.ReactElement | null {
|
||||
key={list.id}
|
||||
type="button"
|
||||
onClick={() => handleToggle(list)}
|
||||
disabled={busy}
|
||||
disabled={busyId != null}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl border text-left transition-colors disabled:opacity-60 ${saved ? 'border-accent bg-accent-subtle' : 'border-edge bg-surface-card hover:bg-surface-hover'}`}
|
||||
>
|
||||
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ background: list.color || 'var(--accent)' }} />
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// FE-COMP-SAVETRIPPL-001 to FE-COMP-SAVETRIPPL-015
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { Collection, CollectionListResponse } from '@trek/shared';
|
||||
import { collectionsApi } from '../../api/collections';
|
||||
import SaveTripPlacesToListModal from './SaveTripPlacesToListModal';
|
||||
|
||||
type ModalProps = React.ComponentProps<typeof SaveTripPlacesToListModal>;
|
||||
type SaveManyResult = { copied: number; skipped: { id: number; name: string }[] };
|
||||
|
||||
function list(over: Partial<Collection>): Collection {
|
||||
return { id: 1, owner_id: 7, name: 'List', color: null, place_count: 0, ...over } as Collection;
|
||||
}
|
||||
|
||||
const FAVORITES = list({ id: 1, name: 'Favorites', color: '#ef4444', place_count: 4 });
|
||||
const WISHLIST = list({ id: 2, name: 'Wishlist' });
|
||||
|
||||
const listResponse = (collections: Collection[]): CollectionListResponse => ({ collections, incomingInvites: [] });
|
||||
|
||||
let addToast: ReturnType<typeof vi.fn>;
|
||||
|
||||
function renderModal(overrides: Partial<ModalProps> = {}) {
|
||||
const props: ModalProps = {
|
||||
isOpen: true,
|
||||
tripId: 5,
|
||||
placeIds: [11, 12],
|
||||
onClose: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
render(<SaveTripPlacesToListModal {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
addToast = vi.fn();
|
||||
window.__addToast = addToast as unknown as typeof window.__addToast;
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES, WISHLIST]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
describe('SaveTripPlacesToListModal', () => {
|
||||
it('FE-COMP-SAVETRIPPL-001: titles the modal with the selection count and lists the writable lists', async () => {
|
||||
renderModal({ placeIds: [11, 12, 13] });
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Save 3 to a list' })).toBeInTheDocument();
|
||||
expect(screen.getByText('4 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('0 places')).toBeInTheDocument();
|
||||
expect(screen.getByText('Duplicates are skipped automatically')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-002: lists shared with the user are dropped — they cannot be written to', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
|
||||
listResponse([FAVORITES, list({ id: 3, name: 'Team ideas', is_owner: false })]),
|
||||
);
|
||||
renderModal();
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Team ideas')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-003: shows the spinner until the lists arrive', async () => {
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
renderModal();
|
||||
expect(document.querySelector('.animate-spin')).not.toBeNull();
|
||||
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
|
||||
|
||||
resolve(listResponse([FAVORITES]));
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-004: an absent or failing collections response falls back to the empty copy', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue({ incomingInvites: [] } as unknown as CollectionListResponse);
|
||||
const { unmount } = render(<SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />);
|
||||
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
vi.spyOn(collectionsApi, 'list').mockRejectedValue(new Error('offline'));
|
||||
renderModal();
|
||||
expect(await screen.findByText('You have no lists yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-005: requests nothing and renders nothing while closed', () => {
|
||||
const spy = vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse([FAVORITES]));
|
||||
renderModal({ isOpen: false });
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('heading', { name: /Save/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-006: the search box appears above five lists and filters by name', async () => {
|
||||
const many = [1, 2, 3, 4, 5, 6].map(id => list({ id, name: `List ${id}` }));
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(listResponse(many));
|
||||
renderModal();
|
||||
|
||||
const search = await screen.findByPlaceholderText('Search lists');
|
||||
fireEvent.change(search, { target: { value: 'list 4' } });
|
||||
expect(screen.getByText('List 4')).toBeInTheDocument();
|
||||
expect(screen.queryByText('List 5')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(search, { target: { value: 'nope' } });
|
||||
expect(screen.getByText('You have no lists yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-007: five lists or fewer need no search box', async () => {
|
||||
renderModal();
|
||||
await screen.findByText('Favorites');
|
||||
expect(screen.queryByPlaceholderText('Search lists')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-008: picking a list copies every selected place, reports it and closes', async () => {
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 2, skipped: [] });
|
||||
const props = renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
|
||||
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
|
||||
expect(save).toHaveBeenCalledWith(1, 5, [11, 12]);
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 2 to Favorites', 'success', undefined);
|
||||
expect(props.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-009: server-side duplicates are reported separately', async () => {
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 1, skipped: [{ id: 12, name: 'Louvre' }] });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledWith('Saved 1 to Favorites', 'success', undefined);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-010: a no-op save says so instead of staying silent', async () => {
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockResolvedValue({ copied: 0, skipped: [] });
|
||||
renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined));
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-011: a failed save surfaces the server message and keeps the modal open', async () => {
|
||||
vi.spyOn(collectionsApi, 'saveFromTripMany').mockRejectedValue({ response: { data: { error: 'List is full' } } });
|
||||
const props = renderModal();
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('List is full', 'error', undefined));
|
||||
expect(props.onDone).not.toHaveBeenCalled();
|
||||
expect(props.onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-012: every row locks while a save runs, and an empty selection never reaches the server', async () => {
|
||||
let resolve!: (v: SaveManyResult) => void;
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
const props = renderModal();
|
||||
|
||||
const favorites = await screen.findByRole('button', { name: /Favorites/ });
|
||||
fireEvent.click(favorites);
|
||||
const wishlist = screen.getByRole('button', { name: /Wishlist/ });
|
||||
expect(wishlist).toBeDisabled();
|
||||
expect(within(favorites).queryByText('4 places')).toBeInTheDocument();
|
||||
fireEvent.click(wishlist);
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({ copied: 1, skipped: [] });
|
||||
await waitFor(() => expect(props.onDone).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-014: a list response landing after the modal closed is dropped', async () => {
|
||||
const closed = <SaveTripPlacesToListModal isOpen tripId={5} placeIds={[11]} onClose={vi.fn()} onDone={vi.fn()} />;
|
||||
|
||||
let resolve!: (v: CollectionListResponse) => void;
|
||||
const spy = vi.spyOn(collectionsApi, 'list').mockReturnValue(new Promise(r => { resolve = r; }));
|
||||
render(closed).unmount();
|
||||
resolve(listResponse([FAVORITES]));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument();
|
||||
|
||||
let reject!: (e: Error) => void;
|
||||
spy.mockReturnValue(new Promise((_, r) => { reject = r; }));
|
||||
render(closed).unmount();
|
||||
reject(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
expect(screen.queryByText('You have no lists yet')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-015: a list the server sent without a count still renders a countable row', async () => {
|
||||
vi.spyOn(collectionsApi, 'list').mockResolvedValue(
|
||||
listResponse([{ id: 9, owner_id: 7, name: 'Untracked' } as Collection]),
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const untracked = await screen.findByRole('button', { name: /Untracked/ });
|
||||
expect(within(untracked).getByText('0 places')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-SAVETRIPPL-013: an empty selection is a no-op, and Escape hands back to the caller', async () => {
|
||||
const user = userEvent.setup();
|
||||
const save = vi.spyOn(collectionsApi, 'saveFromTripMany');
|
||||
const props = renderModal({ placeIds: [] });
|
||||
|
||||
fireEvent.click(await screen.findByText('Favorites'));
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export default function SaveTripPlacesToListModal({ isOpen, tripId, placeIds, on
|
||||
autoFocus
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('collections.copyToTripSearch')}
|
||||
placeholder={t('collections.searchLists')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg border border-edge bg-surface-input text-content text-[13px] outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
// FE-COMP-COLSHARE-001 to FE-COMP-COLSHARE-025
|
||||
import React from 'react'
|
||||
import type { Mock } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, within } from '../../../tests/helpers/render'
|
||||
import type { CollectionMember } from '@trek/shared'
|
||||
import { collectionsApi } from '../../api/collections'
|
||||
import { useCollectionStore } from '../../store/collectionStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { buildUser } from '../../../tests/helpers/factories'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import ShareCollectionModal from './ShareCollectionModal'
|
||||
|
||||
type Props = React.ComponentProps<typeof ShareCollectionModal>
|
||||
function Harness(props: Omit<Props, 't'>): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return <ShareCollectionModal {...props} t={t} />
|
||||
}
|
||||
|
||||
const OWNER: CollectionMember = { user_id: 1, username: 'maurice', email: 'm@example.com', status: 'accepted', is_owner: true }
|
||||
const EDITOR: CollectionMember = { user_id: 2, username: 'julien', email: 'j@example.com', status: 'accepted', role: 'editor', avatar: 'jul.png' }
|
||||
const VIEWER: CollectionMember = { user_id: 4, username: 'ada', email: 'a@example.com', status: 'accepted', role: 'viewer' }
|
||||
const PENDING: CollectionMember = { user_id: 3, username: 'zoe', email: 'z@example.com', status: 'pending' }
|
||||
|
||||
type CollectionStore = ReturnType<typeof useCollectionStore.getState>
|
||||
type AddToast = NonNullable<typeof window.__addToast>
|
||||
|
||||
const initialCollectionState = useCollectionStore.getState()
|
||||
let actions: {
|
||||
invite: Mock<CollectionStore['invite']>
|
||||
cancelInvite: Mock<CollectionStore['cancelInvite']>
|
||||
removeMember: Mock<CollectionStore['removeMember']>
|
||||
setMemberRole: Mock<CollectionStore['setMemberRole']>
|
||||
leave: Mock<CollectionStore['leave']>
|
||||
}
|
||||
let addToast: Mock<AddToast>
|
||||
|
||||
function setup(over: Partial<Omit<Props, 't'>> = {}) {
|
||||
const props: Omit<Props, 't'> = {
|
||||
isOpen: true,
|
||||
collectionId: 7,
|
||||
collectionName: 'Tokyo 2026',
|
||||
isOwner: true,
|
||||
members: [PENDING, EDITOR, OWNER],
|
||||
onClose: vi.fn(),
|
||||
onAfterLeave: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
const view = render(<Harness {...props} />)
|
||||
return { ...view, props }
|
||||
}
|
||||
|
||||
/** The roster row for a member — the invite form carries the same role labels,
|
||||
* so role assertions have to be scoped to the row they belong to. */
|
||||
function memberRow(username: string): HTMLElement {
|
||||
const nameCell = screen.getByText(username).parentElement as HTMLElement
|
||||
return nameCell.parentElement as HTMLElement
|
||||
}
|
||||
|
||||
describe('ShareCollectionModal', () => {
|
||||
beforeEach(() => {
|
||||
resetAllStores()
|
||||
useCollectionStore.setState(initialCollectionState, true)
|
||||
actions = {
|
||||
invite: vi.fn<CollectionStore['invite']>(async () => undefined),
|
||||
cancelInvite: vi.fn<CollectionStore['cancelInvite']>(async () => undefined),
|
||||
removeMember: vi.fn<CollectionStore['removeMember']>(async () => undefined),
|
||||
setMemberRole: vi.fn<CollectionStore['setMemberRole']>(async () => undefined),
|
||||
leave: vi.fn<CollectionStore['leave']>(async () => undefined),
|
||||
}
|
||||
useCollectionStore.setState(actions)
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) })
|
||||
addToast = vi.fn<AddToast>(() => 0)
|
||||
window.__addToast = addToast
|
||||
vi.spyOn(collectionsApi, 'availableUsers').mockResolvedValue({
|
||||
users: [{ id: 9, username: 'nina' }, { id: 10, username: 'omar' }],
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
delete window.__addToast
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-001: a closed modal renders nothing and asks for no invitable users', () => {
|
||||
setup({ isOpen: false })
|
||||
expect(screen.queryByRole('heading', { name: /Share/ })).not.toBeInTheDocument()
|
||||
expect(collectionsApi.availableUsers).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-002: the title names the list and the roster is counted', () => {
|
||||
setup()
|
||||
expect(screen.getByRole('heading', { name: 'Share “Tokyo 2026”' })).toBeInTheDocument()
|
||||
expect(screen.getByText('Members')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-003: sorts owner first, then accepted members, then pending invites', () => {
|
||||
setup({ members: [PENDING, VIEWER, EDITOR, OWNER] })
|
||||
const names = screen.getAllByText(/^(maurice|julien|ada|zoe)/).map(n => n.textContent)
|
||||
expect(names).toEqual(['maurice(you)', 'ada', 'julien', 'zoe'])
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-004: tags the owner, marks the signed-in user and hides a pending email', () => {
|
||||
setup()
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument()
|
||||
expect(screen.getByText('(you)')).toBeInTheDocument()
|
||||
expect(screen.getByText('j@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('pending invite')).toBeInTheDocument()
|
||||
expect(screen.queryByText('z@example.com')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-005: renders an uploaded avatar, otherwise the username initial', () => {
|
||||
setup()
|
||||
const avatars = document.querySelectorAll('img')
|
||||
expect(avatars).toHaveLength(1)
|
||||
expect(avatars[0]).toHaveAttribute('src', '/uploads/avatars/jul.png')
|
||||
expect(screen.getByText('M')).toBeInTheDocument()
|
||||
expect(screen.getByText('Z')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-006: a member without a username still gets a placeholder initial', () => {
|
||||
setup({ members: [OWNER, { ...EDITOR, username: '', avatar: undefined }] })
|
||||
expect(screen.getByText('?')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-007: the owner changes a member role through the role select', async () => {
|
||||
setup({ members: [OWNER, EDITOR] })
|
||||
// The select shows the member's current role; open it and pick another.
|
||||
fireEvent.click(within(memberRow('julien')).getByRole('button', { name: 'Editor' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
|
||||
await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin'))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-008: a member with no explicit role defaults to editor in the select', () => {
|
||||
setup({ members: [OWNER, { ...EDITOR, role: undefined }] })
|
||||
expect(within(memberRow('julien')).getByRole('button', { name: 'Editor' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-009: a failing role change surfaces the server message', async () => {
|
||||
actions.setMemberRole.mockRejectedValue({ response: { data: { error: 'Not allowed' } } })
|
||||
setup({ members: [OWNER, EDITOR] })
|
||||
fireEvent.click(within(memberRow('julien')).getByRole('button', { name: 'Editor' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Viewer' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-010: the owner removes an accepted member and cancels a pending invite', async () => {
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
await waitFor(() => expect(actions.removeMember).toHaveBeenCalledWith(7, 2))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-011: a second remove is ignored while the first is still running', async () => {
|
||||
let release!: () => void
|
||||
actions.removeMember.mockReturnValue(new Promise<void>(r => { release = r }))
|
||||
setup({ members: [OWNER, EDITOR, VIEWER] })
|
||||
const [first, second] = screen.getAllByRole('button', { name: 'Remove' })
|
||||
fireEvent.click(first)
|
||||
fireEvent.click(second)
|
||||
expect(actions.removeMember).toHaveBeenCalledTimes(1)
|
||||
expect(first).toBeDisabled()
|
||||
|
||||
release()
|
||||
await waitFor(() => expect(first).not.toBeDisabled())
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-012: a failing cancel reports the fallback error', async () => {
|
||||
actions.cancelInvite.mockRejectedValue({})
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-013: a failing remove reports the fallback error', async () => {
|
||||
actions.removeMember.mockRejectedValue({})
|
||||
setup()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Error', 'error', undefined))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-014: the owner picks a user and role, then sends the invite', async () => {
|
||||
setup({ members: [OWNER] })
|
||||
expect(await screen.findByRole('button', { name: /Send invite/ })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Select a user' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'omar' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Editor' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
|
||||
|
||||
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 10, 'admin'))
|
||||
expect(addToast).toHaveBeenCalledWith('Invite sent', 'success', undefined)
|
||||
// The picker falls back to its placeholder for the next invite.
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Select a user' })).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-015: editor is the preselected invite role', async () => {
|
||||
setup({ members: [OWNER] })
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select a user' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
|
||||
await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 9, 'editor'))
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-016: a failing invite keeps the selection and reports the error', async () => {
|
||||
actions.invite.mockRejectedValue(new Error('boom'))
|
||||
setup({ members: [OWNER] })
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Select a user' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'nina' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Send invite/ }))
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not send invite', 'error', undefined))
|
||||
expect(screen.getByRole('button', { name: 'nina' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-017: with nobody left to invite the owner is told so', async () => {
|
||||
vi.mocked(collectionsApi.availableUsers).mockResolvedValue({ users: [] })
|
||||
setup()
|
||||
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Send invite/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-018: a failing lookup degrades to the same empty state', async () => {
|
||||
vi.mocked(collectionsApi.availableUsers).mockRejectedValue(new Error('offline'))
|
||||
setup()
|
||||
expect(await screen.findByText('No users available to invite.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-019: members see a read-only roster, no invite form and no row actions', async () => {
|
||||
setup({ isOwner: false, members: [OWNER, EDITOR, VIEWER] })
|
||||
expect(screen.getByText('Only the list owner can invite or remove people.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument()
|
||||
expect(screen.getByText('Viewer')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /Send invite/ })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(collectionsApi.availableUsers).not.toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-020: a member without a role reads as editor in the read-only badge', () => {
|
||||
setup({ isOwner: false, members: [OWNER, { ...EDITOR, role: undefined }] })
|
||||
expect(screen.getByText('Editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-021: leaving asks for confirmation first and can be backed out of', () => {
|
||||
setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(actions.leave).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-022: a confirmed leave calls the store and hands back to the page', async () => {
|
||||
const { props } = setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
|
||||
await waitFor(() => expect(actions.leave).toHaveBeenCalledWith(7))
|
||||
expect(addToast).toHaveBeenCalledWith('You left the list', 'success', undefined)
|
||||
expect(props.onAfterLeave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-023: a failing leave reports the error and keeps the user in the list', async () => {
|
||||
actions.leave.mockRejectedValue({ response: { data: { error: 'Owners cannot leave' } } })
|
||||
const { props } = setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'Leave list' })[0])
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith('Owners cannot leave', 'error', undefined))
|
||||
expect(props.onAfterLeave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-024: closing resets the picker and the leave confirmation', async () => {
|
||||
const { rerender, props } = setup({ isOwner: false })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Leave list' }))
|
||||
expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument()
|
||||
|
||||
rerender(<Harness {...props} isOwner={false} isOpen={false} />)
|
||||
rerender(<Harness {...props} isOwner={false} isOpen />)
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-COLSHARE-025: the modal close button calls onClose', () => {
|
||||
const { props } = setup()
|
||||
// The Modal chrome's only unnamed control is its close button.
|
||||
const header = screen.getByRole('heading', { name: 'Share “Tokyo 2026”' }).parentElement as HTMLElement
|
||||
fireEvent.click(header.querySelector('button') as HTMLButtonElement)
|
||||
expect(props.onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -114,6 +114,7 @@ export default function ShareCollectionModal({
|
||||
}
|
||||
|
||||
const handleSetRole = async (userId: number, role: CollectionRole) => {
|
||||
if (settingRoleId != null) return
|
||||
setSettingRoleId(userId)
|
||||
try {
|
||||
await setMemberRole(collectionId, userId, role)
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { server } from '../../../tests/helpers/msw/server';
|
||||
import CollectionsWidget from './CollectionsWidget';
|
||||
|
||||
// FE-COMP-COLWIDGET-001 onwards
|
||||
|
||||
function collections(list: unknown[]) {
|
||||
server.use(http.get('/api/addons/collections', () => HttpResponse.json({ collections: list })));
|
||||
}
|
||||
|
||||
const list = (over: Record<string, unknown> = {}) => ({
|
||||
id: 1, name: 'Tokyo eats', color: '#ff0000', cover_image: null, place_count: 12, ...over,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
collections([]);
|
||||
});
|
||||
|
||||
describe('CollectionsWidget', () => {
|
||||
it('FE-COMP-COLWIDGET-001: shows the empty hint once the fetch resolves', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
expect(screen.getByText('Collections')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-002: renders at most six badges with their counts', async () => {
|
||||
collections([1, 2, 3, 4, 5, 6, 7].map(id => list({ id, name: `List ${id}`, place_count: id })));
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(await screen.findByText('List 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('List 6')).toBeInTheDocument();
|
||||
expect(screen.queryByText('List 7')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-003: a list without a count renders a zero', async () => {
|
||||
collections([list({ place_count: null })]);
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
await screen.findByText('Tokyo eats');
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-004: a cover image replaces the gradient tile', async () => {
|
||||
collections([list({ cover_image: '/uploads/collections/1.jpg' })]);
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
await screen.findByText('Tokyo eats');
|
||||
expect(container.querySelector('img.col-badge-media')).toHaveAttribute('src', '/uploads/collections/1.jpg');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-005: a list without a colour falls back to the entity gradient', async () => {
|
||||
collections([list({ color: null })]);
|
||||
const { container } = render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
await screen.findByText('Tokyo eats');
|
||||
const media = container.querySelector('div.col-badge-media') as HTMLElement;
|
||||
expect(media.style.backgroundImage).toContain('gradient');
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-006: the header arrow runs the onOpen callback', async () => {
|
||||
const onOpen = vi.fn();
|
||||
render(<CollectionsWidget onOpen={onOpen} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
|
||||
expect(onOpen).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-007: a failing fetch degrades to the empty hint', async () => {
|
||||
server.use(http.get('/api/addons/collections', () => new HttpResponse(null, { status: 500 })));
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(await screen.findByText('No saved places yet')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-COLWIDGET-008: nothing renders while the fetch is still in flight', async () => {
|
||||
render(<CollectionsWidget onOpen={() => {}} />);
|
||||
|
||||
expect(screen.queryByText('No saved places yet')).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText('No saved places yet')).toBeInTheDocument());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// FE-W4FMH-001 to FE-W4FMH-012
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { File as FileIcon, FileText, FileImage, FileVideo, Plane, Train, Bus, Car, CarTaxiFront, Bike, Ship, Sailboat, Route } from 'lucide-react'
|
||||
|
||||
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
vi.mock('../../utils/fileDownload', () => ({ downloadFile: (url: string, name: string) => downloadFile(url, name) }))
|
||||
|
||||
import {
|
||||
isImage, isVideo, isMedia, isMarkdown, getFileIcon, formatSize,
|
||||
triggerDownload, formatDateWithLocale, transportIcon,
|
||||
} from './FileManager.helpers'
|
||||
|
||||
beforeEach(() => {
|
||||
downloadFile.mockReset()
|
||||
downloadFile.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('mime helpers', () => {
|
||||
it('FE-W4FMH-001: detects images and videos', () => {
|
||||
expect(isImage('image/png')).toBe(true)
|
||||
expect(isImage('application/pdf')).toBe(false)
|
||||
expect(isImage(null)).toBe(false)
|
||||
expect(isVideo('video/mp4')).toBe(true)
|
||||
expect(isVideo(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W4FMH-002: treats both images and videos as lightbox media', () => {
|
||||
expect(isMedia('image/jpeg')).toBe(true)
|
||||
expect(isMedia('video/quicktime')).toBe(true)
|
||||
expect(isMedia('application/pdf')).toBe(false)
|
||||
expect(isMedia(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-W4FMH-003: detects markdown by extension first', () => {
|
||||
expect(isMarkdown('application/octet-stream', 'NOTES.MD')).toBe(true)
|
||||
expect(isMarkdown('', 'readme.markdown')).toBe(true)
|
||||
expect(isMarkdown('text/markdown', 'blob')).toBe(true)
|
||||
expect(isMarkdown('text/x-markdown', null)).toBe(true)
|
||||
expect(isMarkdown('text/plain', 'notes.txt')).toBe(false)
|
||||
expect(isMarkdown(null, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileIcon', () => {
|
||||
it('FE-W4FMH-004: picks the icon matching the mime family', () => {
|
||||
expect(getFileIcon('application/pdf')).toBe(FileText)
|
||||
expect(getFileIcon('video/mp4')).toBe(FileVideo)
|
||||
expect(getFileIcon('image/png')).toBe(FileImage)
|
||||
expect(getFileIcon('application/zip')).toBe(FileIcon)
|
||||
expect(getFileIcon(null)).toBe(FileIcon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatSize', () => {
|
||||
it('FE-W4FMH-005: renders bytes below a kilobyte', () => {
|
||||
expect(formatSize(512)).toBe('512 B')
|
||||
})
|
||||
|
||||
it('FE-W4FMH-006: renders kilobytes and megabytes with one decimal', () => {
|
||||
expect(formatSize(2048)).toBe('2.0 KB')
|
||||
expect(formatSize(1024 * 1024 * 3.25)).toBe('3.3 MB')
|
||||
})
|
||||
|
||||
it('FE-W4FMH-007: renders nothing for a missing or zero size', () => {
|
||||
expect(formatSize(0)).toBe('')
|
||||
expect(formatSize(null)).toBe('')
|
||||
expect(formatSize(undefined)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('triggerDownload', () => {
|
||||
it('FE-W4FMH-008: forwards to the download helper', () => {
|
||||
triggerDownload('/uploads/files/a.pdf', 'a.pdf')
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/a.pdf', 'a.pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FMH-009: swallows a rejected download', async () => {
|
||||
downloadFile.mockRejectedValue(new Error('offline'))
|
||||
|
||||
expect(() => triggerDownload('/x', 'x')).not.toThrow()
|
||||
await Promise.resolve()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateWithLocale', () => {
|
||||
it('FE-W4FMH-010: formats an ISO date in the given locale', () => {
|
||||
expect(formatDateWithLocale('2026-06-15T10:00:00Z', 'en-GB')).toBe('15/06/2026')
|
||||
})
|
||||
|
||||
it('FE-W4FMH-011: returns an empty string for a missing or unparseable date', () => {
|
||||
expect(formatDateWithLocale(null, 'en-GB')).toBe('')
|
||||
expect(formatDateWithLocale('', 'en-GB')).toBe('')
|
||||
expect(formatDateWithLocale('2026-06-15', 'en_US')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('transportIcon', () => {
|
||||
it('FE-W4FMH-012: maps every transport type, defaulting to the plane', () => {
|
||||
expect(transportIcon('train')).toBe(Train)
|
||||
expect(transportIcon('bus')).toBe(Bus)
|
||||
expect(transportIcon('car')).toBe(Car)
|
||||
expect(transportIcon('taxi')).toBe(CarTaxiFront)
|
||||
expect(transportIcon('bicycle')).toBe(Bike)
|
||||
expect(transportIcon('cruise')).toBe(Ship)
|
||||
expect(transportIcon('ferry')).toBe(Sailboat)
|
||||
expect(transportIcon('transport_other')).toBe(Route)
|
||||
expect(transportIcon('flight')).toBe(Plane)
|
||||
expect(transportIcon('')).toBe(Plane)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,410 @@
|
||||
// FE-W5ASG-001 to FE-W5ASG-022
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { Day, Place, Reservation, TripFile } from '../../types'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
|
||||
const getLinks = vi.fn(async (_tripId: number, _fileId: number): Promise<{ links?: unknown[] }> => ({ links: [] }))
|
||||
const addLink = vi.fn(async (_tripId: number, _fileId: number, _data: unknown) => ({}))
|
||||
const removeLink = vi.fn(async (_tripId: number, _fileId: number, _linkId: number) => ({}))
|
||||
|
||||
vi.mock('../../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api/client')>()
|
||||
return {
|
||||
...actual,
|
||||
filesApi: {
|
||||
...actual.filesApi,
|
||||
getLinks: (tripId: number, fileId: number) => getLinks(tripId, fileId),
|
||||
addLink: (tripId: number, fileId: number, data: unknown) => addLink(tripId, fileId, data),
|
||||
removeLink: (tripId: number, fileId: number, linkId: number) => removeLink(tripId, fileId, linkId),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { AssignModal } from './FileManagerAssignModal'
|
||||
|
||||
const setAssignFileId = vi.fn()
|
||||
const handleAssign = vi.fn(async (_fileId: number, _data: unknown) => {})
|
||||
const refreshFiles = vi.fn(async () => {})
|
||||
|
||||
const file = (overrides: Partial<TripFile> = {}) =>
|
||||
({ id: 7, original_name: 'ticket.pdf', description: '', url: '/uploads/files/ticket.pdf', ...overrides }) as unknown as TripFile
|
||||
|
||||
const place = (id: number, name: string) => ({ id, name }) as unknown as Place
|
||||
const reservation = (id: number, title: string, type: string) => ({ id, title, type }) as unknown as Reservation
|
||||
const day = (overrides: Partial<Day> = {}) => ({ id: 100, day_number: 1, ...overrides }) as unknown as Day
|
||||
|
||||
function state(overrides: Partial<FileManagerState> = {}): FileManagerState {
|
||||
return {
|
||||
files: [file()],
|
||||
assignFileId: 7,
|
||||
setAssignFileId,
|
||||
t: (key: string) => key,
|
||||
days: [],
|
||||
assignments: {},
|
||||
places: [],
|
||||
reservations: [],
|
||||
tripId: 3,
|
||||
handleAssign,
|
||||
refreshFiles,
|
||||
...overrides,
|
||||
} as unknown as FileManagerState
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getLinks.mockResolvedValue({ links: [] })
|
||||
addLink.mockResolvedValue({})
|
||||
removeLink.mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('AssignModal shell', () => {
|
||||
it('FE-W5ASG-001: portals the modal and names the selected file', () => {
|
||||
const { container } = render(<AssignModal {...state()} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.getByText('files.assignTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-002: the backdrop closes the modal but the card swallows the click', () => {
|
||||
render(<AssignModal {...state()} />)
|
||||
const card = screen.getByText('files.assignTitle').closest('div[style*="border-radius: 16px"]') as HTMLElement
|
||||
|
||||
fireEvent.click(card)
|
||||
expect(setAssignFileId).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(card.parentElement!)
|
||||
expect(setAssignFileId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-003: the header close button clears the selection', () => {
|
||||
render(<AssignModal {...state()} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[0])
|
||||
|
||||
expect(setAssignFileId).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-034: the note label falls back to English when the key is missing', () => {
|
||||
render(<AssignModal {...state({ t: ((key: string) => (key === 'files.noteLabel' ? '' : key)) as FileManagerState['t'] })} />)
|
||||
|
||||
expect(screen.getByText('Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-004: an unknown file id leaves the header blank and the body empty', () => {
|
||||
render(<AssignModal {...state({ assignFileId: 999, places: [place(1, 'Louvre')] })} />)
|
||||
|
||||
expect(screen.queryByText('ticket.pdf')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssignModal note field', () => {
|
||||
it('FE-W5ASG-005: a changed note is persisted on blur', () => {
|
||||
render(<AssignModal {...state()} />)
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
|
||||
fireEvent.blur(input, { target: { value: ' seat 14A ' } })
|
||||
|
||||
expect(handleAssign).toHaveBeenCalledWith(7, { description: 'seat 14A' })
|
||||
})
|
||||
|
||||
it('FE-W5ASG-006: an unchanged note is not persisted', () => {
|
||||
render(<AssignModal {...state({ files: [file({ description: 'seat 14A' })] })} />)
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder')
|
||||
|
||||
fireEvent.blur(input, { target: { value: 'seat 14A' } })
|
||||
|
||||
expect(handleAssign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-007: Enter blurs the note field, other keys do not', () => {
|
||||
render(<AssignModal {...state()} />)
|
||||
const input = screen.getByPlaceholderText('files.notePlaceholder') as HTMLInputElement
|
||||
const blur = vi.spyOn(input, 'blur')
|
||||
|
||||
fireEvent.keyDown(input, { key: 'a' })
|
||||
expect(blur).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(blur).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssignModal place list', () => {
|
||||
const places = [place(1, 'Louvre'), place(2, 'Eiffel Tower'), place(3, 'Sacré-Cœur')]
|
||||
|
||||
it('FE-W5ASG-008: groups places under their day and shows the date badge', () => {
|
||||
render(<AssignModal {...state({
|
||||
places,
|
||||
days: [day({ id: 100, date: '2026-03-15', title: 'Museums' }), day({ id: 101, day_number: 2 })],
|
||||
assignments: { '100': [{ place: { id: 1 } }, { place_id: 2 }] as never },
|
||||
})} />)
|
||||
|
||||
expect(screen.getByText('Museums')).toBeInTheDocument()
|
||||
expect(screen.getByText('2026-03-15')).toBeInTheDocument()
|
||||
// day 2 has no places and is dropped entirely
|
||||
expect(screen.queryByText('dayplan.dayN')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('files.unassigned')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Sacré-Cœur' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-009: a titled day without a date badges the day number', () => {
|
||||
render(<AssignModal {...state({
|
||||
places,
|
||||
days: [day({ title: 'Museums' })],
|
||||
assignments: { '100': [{ place: { id: 1 } }] as never },
|
||||
})} />)
|
||||
|
||||
expect(screen.getAllByText('dayplan.dayN')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-010: an untitled day without a date gets no badge', () => {
|
||||
render(<AssignModal {...state({
|
||||
places,
|
||||
days: [day()],
|
||||
assignments: { '100': [{ place: { id: 1 } }] as never },
|
||||
})} />)
|
||||
|
||||
// only the group heading itself, no badge next to it
|
||||
expect(screen.getAllByText('dayplan.dayN')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-011: without day groups the unassigned heading is dropped', () => {
|
||||
render(<AssignModal {...state({ places })} />)
|
||||
|
||||
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.unassigned')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-012: the first place assignment goes through handleAssign', () => {
|
||||
render(<AssignModal {...state({ places })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Louvre' }))
|
||||
|
||||
expect(handleAssign).toHaveBeenCalledWith(7, { place_id: 1 })
|
||||
expect(addLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-013: a second place becomes an extra link', async () => {
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
|
||||
|
||||
await waitFor(() => expect(addLink).toHaveBeenCalledWith(3, 7, { place_id: 2 }))
|
||||
expect(refreshFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-014: a failing link request is swallowed', async () => {
|
||||
addLink.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
|
||||
|
||||
await waitFor(() => expect(addLink).toHaveBeenCalled())
|
||||
expect(refreshFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-015: clicking the primary place again unassigns it', () => {
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Louvre' }))
|
||||
|
||||
expect(handleAssign).toHaveBeenCalledWith(7, { place_id: null })
|
||||
})
|
||||
|
||||
it('FE-W5ASG-016: clicking a linked place removes just that link', async () => {
|
||||
getLinks.mockResolvedValueOnce({ links: [{ id: 55, place_id: 2 }] })
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
|
||||
|
||||
await waitFor(() => expect(removeLink).toHaveBeenCalledWith(3, 7, 55))
|
||||
expect(refreshFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-017: a linked place with no matching link row still refreshes', async () => {
|
||||
getLinks.mockResolvedValueOnce({})
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
|
||||
|
||||
await waitFor(() => expect(refreshFiles).toHaveBeenCalled())
|
||||
expect(removeLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-018: a failing links lookup is swallowed', async () => {
|
||||
getLinks.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1, linked_place_ids: [2] })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Eiffel Tower' }))
|
||||
|
||||
await waitFor(() => expect(getLinks).toHaveBeenCalled())
|
||||
expect(refreshFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-019: rows keep the linked highlight on mouse-out, free rows do not', () => {
|
||||
render(<AssignModal {...state({ places, files: [file({ place_id: 1 })] })} />)
|
||||
const linked = screen.getByRole('button', { name: 'Louvre' })
|
||||
const free = screen.getByRole('button', { name: 'Eiffel Tower' })
|
||||
|
||||
expect(linked.style.fontWeight).toBe('600')
|
||||
fireEvent.mouseEnter(linked)
|
||||
fireEvent.mouseLeave(linked)
|
||||
expect(linked.style.background).toBe('var(--bg-hover)')
|
||||
|
||||
fireEvent.mouseEnter(free)
|
||||
expect(free.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(free)
|
||||
expect(free.style.background).toBe('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssignModal reservation list', () => {
|
||||
const bookings = [reservation(10, 'Hotel Lutetia', 'hotel')]
|
||||
const transports = [reservation(20, 'AF1234', 'flight'), reservation(21, 'TGV 8712', 'train')]
|
||||
|
||||
it('FE-W5ASG-020: splits bookings from transports and gives each its own icon', () => {
|
||||
render(<AssignModal {...state({ reservations: [...bookings, ...transports] })} />)
|
||||
const iconOf = (name: string) =>
|
||||
screen.getByRole('button', { name }).querySelector('svg')?.getAttribute('class') ?? ''
|
||||
|
||||
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
|
||||
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
|
||||
expect(iconOf('Hotel Lutetia')).toMatch(/ticket/)
|
||||
expect(iconOf('TGV 8712')).toMatch(/tram-front/)
|
||||
expect(iconOf('AF1234')).toMatch(/plane/)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-021: a transport-only trip shows no booking heading', () => {
|
||||
render(<AssignModal {...state({ reservations: transports })} />)
|
||||
|
||||
expect(screen.queryByText('files.assignBooking')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('files.assignTransport')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-022: a booking-only trip shows no transport heading', () => {
|
||||
render(<AssignModal {...state({ reservations: bookings })} />)
|
||||
|
||||
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.assignTransport')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-023: the first reservation assignment goes through handleAssign', () => {
|
||||
render(<AssignModal {...state({ reservations: bookings })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Lutetia' }))
|
||||
|
||||
expect(handleAssign).toHaveBeenCalledWith(7, { reservation_id: 10 })
|
||||
})
|
||||
|
||||
it('FE-W5ASG-024: a second reservation becomes an extra link', async () => {
|
||||
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
|
||||
|
||||
await waitFor(() => expect(addLink).toHaveBeenCalledWith(3, 7, { reservation_id: 20 }))
|
||||
expect(refreshFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-025: a failing reservation link is swallowed', async () => {
|
||||
addLink.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
|
||||
|
||||
await waitFor(() => expect(addLink).toHaveBeenCalled())
|
||||
expect(refreshFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-026: clicking the primary reservation again unassigns it', () => {
|
||||
render(<AssignModal {...state({ reservations: bookings, files: [file({ reservation_id: 10 })] })} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hotel Lutetia' }))
|
||||
|
||||
expect(handleAssign).toHaveBeenCalledWith(7, { reservation_id: null })
|
||||
})
|
||||
|
||||
it('FE-W5ASG-027: clicking a linked reservation removes just that link', async () => {
|
||||
getLinks.mockResolvedValueOnce({ links: [{ id: 66, reservation_id: 20 }] })
|
||||
render(<AssignModal {...state({
|
||||
reservations: [...bookings, ...transports],
|
||||
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
|
||||
})} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
|
||||
|
||||
await waitFor(() => expect(removeLink).toHaveBeenCalledWith(3, 7, 66))
|
||||
expect(refreshFiles).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-028: a linked reservation with no matching link row still refreshes', async () => {
|
||||
getLinks.mockResolvedValueOnce({})
|
||||
render(<AssignModal {...state({
|
||||
reservations: [...bookings, ...transports],
|
||||
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
|
||||
})} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
|
||||
|
||||
await waitFor(() => expect(refreshFiles).toHaveBeenCalled())
|
||||
expect(removeLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-029: a failing reservation links lookup is swallowed', async () => {
|
||||
getLinks.mockRejectedValueOnce(new Error('offline'))
|
||||
render(<AssignModal {...state({
|
||||
reservations: [...bookings, ...transports],
|
||||
files: [file({ reservation_id: 10, linked_reservation_ids: [20] })],
|
||||
})} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AF1234' }))
|
||||
|
||||
await waitFor(() => expect(getLinks).toHaveBeenCalled())
|
||||
expect(refreshFiles).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-030: reservation rows keep the linked highlight on mouse-out', () => {
|
||||
render(<AssignModal {...state({ reservations: [...bookings, ...transports], files: [file({ reservation_id: 10 })] })} />)
|
||||
const linked = screen.getByRole('button', { name: 'Hotel Lutetia' })
|
||||
const free = screen.getByRole('button', { name: 'TGV 8712' })
|
||||
|
||||
fireEvent.mouseEnter(linked)
|
||||
fireEvent.mouseLeave(linked)
|
||||
expect(linked.style.background).toBe('var(--bg-hover)')
|
||||
|
||||
fireEvent.mouseEnter(free)
|
||||
expect(free.style.background).toBe('var(--bg-hover)')
|
||||
fireEvent.mouseLeave(free)
|
||||
expect(free.style.background).toBe('transparent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AssignModal split layout', () => {
|
||||
it('FE-W5ASG-031: places and bookings sit side by side when both exist', () => {
|
||||
const { baseElement } = render(<AssignModal {...state({
|
||||
places: [place(1, 'Louvre')],
|
||||
reservations: [reservation(10, 'Hotel Lutetia', 'hotel')],
|
||||
})} />)
|
||||
|
||||
expect(baseElement.querySelector('.md\\:flex')).not.toBeNull()
|
||||
expect(baseElement.querySelectorAll('.md\\:w-1\\/2')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-W5ASG-032: a places-only trip renders a single column', () => {
|
||||
const { baseElement } = render(<AssignModal {...state({ places: [place(1, 'Louvre')] })} />)
|
||||
|
||||
expect(baseElement.querySelector('.md\\:flex')).toBeNull()
|
||||
expect(screen.getByText('files.assignPlace')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.assignBooking')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5ASG-033: a bookings-only trip renders a single column', () => {
|
||||
const { baseElement } = render(<AssignModal {...state({ reservations: [reservation(10, 'Hotel Lutetia', 'hotel')] })} />)
|
||||
|
||||
expect(baseElement.querySelector('.md\\:flex')).toBeNull()
|
||||
expect(screen.getByText('files.assignBooking')).toBeInTheDocument()
|
||||
expect(screen.queryByText('files.assignPlace')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
// FE-W4AVC-001 to FE-W4AVC-006
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render'
|
||||
import { AvatarChip } from './FileManagerAvatarChip'
|
||||
|
||||
describe('AvatarChip', () => {
|
||||
it('FE-W4AVC-001: falls back to the uppercased initial without an avatar', () => {
|
||||
const { container } = render(<AvatarChip name="ada" />)
|
||||
|
||||
expect(container.firstElementChild).toHaveTextContent('A')
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4AVC-002: renders the avatar image when one is given', () => {
|
||||
const { container } = render(<AvatarChip name="ada" avatarUrl="/uploads/avatars/ada.png" />)
|
||||
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/ada.png')
|
||||
expect(container.firstElementChild).not.toHaveTextContent('A')
|
||||
})
|
||||
|
||||
it('FE-W4AVC-003: sizes the chip and its glyph from the size prop', () => {
|
||||
const { container } = render(<AvatarChip name="ada" size={30} />)
|
||||
const chip = container.firstElementChild as HTMLElement
|
||||
|
||||
expect(chip.style.width).toBe('30px')
|
||||
expect(chip.style.height).toBe('30px')
|
||||
expect(chip.style.fontSize).toBe('12px')
|
||||
})
|
||||
|
||||
it('FE-W4AVC-004: defaults to a 20px chip', () => {
|
||||
const { container } = render(<AvatarChip name="ada" />)
|
||||
|
||||
expect((container.firstElementChild as HTMLElement).style.width).toBe('20px')
|
||||
})
|
||||
|
||||
it('FE-W4AVC-005: hovering portals a name tooltip and leaving removes it', () => {
|
||||
const { container } = render(<AvatarChip name="Ada Lovelace" />)
|
||||
const chip = container.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.mouseEnter(chip)
|
||||
const tip = screen.getByText('Ada Lovelace')
|
||||
expect(tip).toBeInTheDocument()
|
||||
expect(container.contains(tip)).toBe(false)
|
||||
|
||||
fireEvent.mouseLeave(chip)
|
||||
expect(screen.queryByText('Ada Lovelace')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4AVC-006: renders nothing in the chip for an empty name', () => {
|
||||
const { container } = render(<AvatarChip name="" />)
|
||||
|
||||
expect(container.firstElementChild).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Fragment } from 'react'
|
||||
import { Upload, FileText, Star } from 'lucide-react'
|
||||
import { Upload, Star } from 'lucide-react'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
import { FileRow } from './FileManagerRow'
|
||||
import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
export function FilesView(S: FileManagerState) {
|
||||
const {
|
||||
@@ -66,11 +67,7 @@ export function FilesView(S: FileManagerState) {
|
||||
{/* File list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 28px 16px' }} className="max-md:!px-4">
|
||||
{filteredFiles.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||
</div>
|
||||
<EmptyState scene="files" title={t('files.empty')} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{filteredFiles.map(file => {
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// FE-W4LBX-001 to FE-W4LBX-019
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { TripFile } from '../../types'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
|
||||
const getAuthUrl = vi.fn(async (url: string, _kind: string) => `${url}?token=abc`)
|
||||
const openFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (url: string, kind: string) => getAuthUrl(url, kind) }))
|
||||
vi.mock('../../utils/fileDownload', () => ({
|
||||
openFile: (url: string, name: string) => openFile(url, name),
|
||||
downloadFile: (url: string, name: string) => downloadFile(url, name),
|
||||
}))
|
||||
vi.mock('../Journey/VideoPlayer', () => ({
|
||||
default: ({ src }: { src: string }) => <div data-testid="video" data-src={src} />,
|
||||
}))
|
||||
|
||||
import { ImageLightbox } from './FileManagerImageLightbox'
|
||||
|
||||
type LightboxFile = TripFile & { url: string }
|
||||
|
||||
function file(overrides: Partial<LightboxFile> = {}): LightboxFile {
|
||||
return { id: 1, original_name: 'beach.jpg', mime_type: 'image/jpeg', url: '/uploads/files/beach.jpg', ...overrides } as unknown as LightboxFile
|
||||
}
|
||||
|
||||
const IMAGES = [
|
||||
file({ id: 1, original_name: 'a.jpg', url: '/f/a.jpg' }),
|
||||
file({ id: 2, original_name: 'b.jpg', url: '/f/b.jpg' }),
|
||||
file({ id: 3, original_name: 'c.jpg', url: '/f/c.jpg' }),
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
getAuthUrl.mockReset()
|
||||
getAuthUrl.mockImplementation(async (url: string) => `${url}?token=abc`)
|
||||
openFile.mockReset()
|
||||
openFile.mockResolvedValue(undefined)
|
||||
downloadFile.mockReset()
|
||||
downloadFile.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('ImageLightbox', () => {
|
||||
it('FE-W4LBX-001: renders nothing when the index points past the list', () => {
|
||||
const { container } = render(<ImageLightbox files={[]} initialIndex={0} onClose={() => {}} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-002: shows the file name and its position in the gallery', () => {
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
|
||||
|
||||
expect(screen.getByText('b.jpg', { exact: false })).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-003: mints a signed download url for the shown image', async () => {
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull())
|
||||
expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download')
|
||||
expect(container.querySelector('img[alt="a.jpg"]')).toHaveAttribute('src', '/f/a.jpg?token=abc')
|
||||
})
|
||||
|
||||
it('FE-W4LBX-004: hides the previous arrow on the first and the next arrow on the last file', () => {
|
||||
const first = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
// header (3) + next arrow + 3 thumbnails
|
||||
expect(first.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(0)
|
||||
expect(first.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(1)
|
||||
first.unmount()
|
||||
|
||||
const last = render(<ImageLightbox files={IMAGES} initialIndex={2} onClose={() => {}} />)
|
||||
expect(last.container.querySelectorAll('.lucide-chevron-left')).toHaveLength(1)
|
||||
expect(last.container.querySelectorAll('.lucide-chevron-right')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-W4LBX-005: the arrows page through the gallery', () => {
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
fireEvent.click(container.querySelector('.lucide-chevron-right')!.closest('button')!)
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(container.querySelector('.lucide-chevron-left')!.closest('button')!)
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-006: the arrow keys page and Escape closes', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-007: paging never runs past either end', () => {
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-008: a swipe pages in the swiped direction', () => {
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
|
||||
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 100 }] })
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.touchStart(root, { touches: [{ clientX: 100 }] })
|
||||
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 220 }] })
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-009: a short swipe is ignored', () => {
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
|
||||
const root = container.firstElementChild as HTMLElement
|
||||
|
||||
fireEvent.touchStart(root, { touches: [{ clientX: 200 }] })
|
||||
fireEvent.touchEnd(root, { changedTouches: [{ clientX: 180 }] })
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-010: a touch end without a start is ignored', () => {
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={1} onClose={() => {}} />)
|
||||
|
||||
fireEvent.touchEnd(container.firstElementChild!, { changedTouches: [{ clientX: 0 }] })
|
||||
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-011: the header buttons open, download and close', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTitle(/open/i))
|
||||
expect(openFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
|
||||
|
||||
fireEvent.click(screen.getByTitle(/download/i))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/f/a.jpg', 'a.jpg')
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-012: clicking the backdrop closes but clicking the image does not', async () => {
|
||||
const onClose = vi.fn()
|
||||
const { container } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={onClose} />)
|
||||
|
||||
await waitFor(() => expect(container.querySelector('img[alt="a.jpg"]')).not.toBeNull())
|
||||
fireEvent.click(container.querySelector('img[alt="a.jpg"]')!)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(container.firstElementChild!)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-013: the thumbnail strip jumps to the picked file', async () => {
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
// header: open + download + close, then the next arrow, then three thumbs.
|
||||
const thumbs = screen.getAllByRole('button').slice(-3)
|
||||
|
||||
fireEvent.click(thumbs[2])
|
||||
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
|
||||
})
|
||||
|
||||
it('FE-W4LBX-014: hides the strip for a single file', () => {
|
||||
render(<ImageLightbox files={[IMAGES[0]]} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
expect(screen.getAllByRole('button')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('FE-W4LBX-015: a video plays in the player and never mints a download token', () => {
|
||||
const video = file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })
|
||||
const { container } = render(<ImageLightbox files={[video]} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
expect(screen.getByTestId('video')).toHaveAttribute('data-src', '/f/clip.mp4')
|
||||
expect(getAuthUrl).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-016: a video thumbnail is a play glyph, not an image request', async () => {
|
||||
const files = [IMAGES[0], file({ id: 9, original_name: 'clip.mp4', mime_type: 'video/mp4', url: '/f/clip.mp4' })]
|
||||
const { container } = render(<ImageLightbox files={files} initialIndex={0} onClose={() => {}} />)
|
||||
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
|
||||
expect(getAuthUrl).not.toHaveBeenCalledWith('/f/clip.mp4', 'download')
|
||||
expect(container.querySelector('.lucide-play')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-017: clicking the video wrapper does not close the lightbox', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<ImageLightbox files={[file({ mime_type: 'video/mp4' })]} initialIndex={0} onClose={onClose} />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('video').parentElement!)
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-018: Escape reaches the current onClose after a re-render', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const { rerender } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={first} />)
|
||||
|
||||
rerender(<ImageLightbox files={IMAGES} initialIndex={0} onClose={second} />)
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
|
||||
expect(second).toHaveBeenCalledOnce()
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-W4LBX-019: the strip mints thumbnail tokens only for thumbs in view', async () => {
|
||||
// Every thumbnail costs its own one-shot token, so an off-screen thumb must
|
||||
// stay quiet. The default observer stub never intersects.
|
||||
const { unmount } = render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/a.jpg', 'download'))
|
||||
expect(getAuthUrl).toHaveBeenCalledTimes(1)
|
||||
unmount()
|
||||
|
||||
getAuthUrl.mockClear()
|
||||
const original = globalThis.IntersectionObserver
|
||||
globalThis.IntersectionObserver = class {
|
||||
constructor(private cb: IntersectionObserverCallback) {}
|
||||
observe() { this.cb([{ isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver) }
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
takeRecords() { return [] }
|
||||
} as unknown as typeof IntersectionObserver
|
||||
try {
|
||||
render(<ImageLightbox files={IMAGES} initialIndex={0} onClose={() => {}} />)
|
||||
await waitFor(() => expect(getAuthUrl).toHaveBeenCalledWith('/f/c.jpg', 'download'))
|
||||
} finally {
|
||||
globalThis.IntersectionObserver = original
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ExternalLink, Download, X, ChevronLeft, ChevronRight, Play } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { TripFile } from '../../types'
|
||||
@@ -41,7 +41,7 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [])
|
||||
}, [onClose])
|
||||
|
||||
if (!file) return null
|
||||
|
||||
@@ -128,11 +128,30 @@ export function ImageLightbox({ files, initialIndex, onClose }: ImageLightboxPro
|
||||
function ThumbImg({ file, active, onClick }: { file: TripFile & { url: string }; active: boolean; onClick: () => void }) {
|
||||
const fileIsVideo = isVideo(file.mime_type)
|
||||
const [src, setSrc] = useState('')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const ref = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Each thumbnail costs its own one-shot download token, so the strip only
|
||||
// mints them for the thumbs that actually scroll into view.
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || typeof IntersectionObserver !== 'function') { setVisible(true); return }
|
||||
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting) { setVisible(true); io.disconnect() } }, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
// Videos have no stored thumbnail and can't render as an <img>; show a play
|
||||
// placeholder and don't mint a download token for them (#823).
|
||||
useEffect(() => { if (!fileIsVideo) getAuthUrl(file.url, 'download').then(setSrc) }, [file.url, fileIsVideo])
|
||||
useEffect(() => {
|
||||
if (!visible || fileIsVideo) return
|
||||
let current = true
|
||||
getAuthUrl(file.url, 'download').then(u => { if (current) setSrc(u) })
|
||||
return () => { current = false }
|
||||
}, [file.url, fileIsVideo, visible])
|
||||
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
<button ref={ref} onClick={onClick} style={{
|
||||
width: 48, height: 48, borderRadius: 6, overflow: 'hidden', border: active ? '2px solid #fff' : '2px solid transparent',
|
||||
opacity: active ? 1 : 0.5, cursor: 'pointer', padding: 0, background: '#111', flexShrink: 0, transition: 'opacity 0.15s',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'rgba(255,255,255,0.7)',
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// FE-W4FPM-001 to FE-W4FPM-014
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import type { TripFile } from '../../types'
|
||||
import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render'
|
||||
import type { FileManagerState } from './useFileManager'
|
||||
|
||||
const openFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
const downloadFile = vi.fn(async (_url: string, _name: string) => {})
|
||||
|
||||
vi.mock('../../utils/fileDownload', () => ({
|
||||
openFile: (url: string, name: string) => openFile(url, name),
|
||||
downloadFile: (url: string, name: string) => downloadFile(url, name),
|
||||
}))
|
||||
|
||||
import { PdfPreviewModal } from './FileManagerPdfPreviewModal'
|
||||
import { MarkdownPreviewModal } from './FileManagerMarkdownPreviewModal'
|
||||
|
||||
const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() }
|
||||
const setPreviewFile = vi.fn()
|
||||
|
||||
function state(overrides: Partial<FileManagerState> = {}): FileManagerState {
|
||||
return {
|
||||
previewFile: { id: 1, original_name: 'ticket.pdf', url: '/uploads/files/ticket.pdf' } as unknown as TripFile,
|
||||
previewFileUrl: '/uploads/files/ticket.pdf?token=abc',
|
||||
setPreviewFile,
|
||||
toast,
|
||||
t: (key: string) => key,
|
||||
...overrides,
|
||||
} as unknown as FileManagerState
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
openFile.mockReset()
|
||||
openFile.mockResolvedValue(undefined)
|
||||
downloadFile.mockReset()
|
||||
downloadFile.mockResolvedValue(undefined)
|
||||
setPreviewFile.mockClear()
|
||||
Object.values(toast).forEach(f => f.mockClear())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('PdfPreviewModal', () => {
|
||||
it('FE-W4FPM-001: portals the viewer with the signed url and the file name', () => {
|
||||
const { container, baseElement } = render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.getByText('ticket.pdf')).toBeInTheDocument()
|
||||
const object = baseElement.querySelector('object') as HTMLObjectElement
|
||||
expect(object).toHaveAttribute('data', '/uploads/files/ticket.pdf?token=abc#view=FitH')
|
||||
expect(object).toHaveAttribute('type', 'application/pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-002: leaves the viewer source unset while no signed url exists yet', () => {
|
||||
const { baseElement } = render(<PdfPreviewModal {...state({ previewFileUrl: null })} />)
|
||||
|
||||
expect(baseElement.querySelector('object')).not.toHaveAttribute('data')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-003: the open-in-tab button uses the unsigned url', () => {
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-004: a failed open toasts the error', async () => {
|
||||
openFile.mockRejectedValue(new Error('blocked'))
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('files.openError'))
|
||||
})
|
||||
|
||||
it('FE-W4FPM-005: the download button hands the file to the download helper', () => {
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-006: the close button and the backdrop clear the preview, the card does not', () => {
|
||||
const { baseElement } = render(<PdfPreviewModal {...state()} />)
|
||||
const backdrop = baseElement.querySelector('div[style*="rgba(0, 0, 0, 0.85)"]') as HTMLElement
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
|
||||
setPreviewFile.mockClear()
|
||||
fireEvent.click(backdrop.firstElementChild!)
|
||||
expect(setPreviewFile).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(backdrop)
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-W4FPM-007: the no-plugin fallback also opens the file', () => {
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.downloadPdf' }))
|
||||
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/ticket.pdf', 'ticket.pdf')
|
||||
})
|
||||
|
||||
it('FE-W4FPM-014: the toolbar buttons brighten on hover and dim again', () => {
|
||||
render(<PdfPreviewModal {...state()} />)
|
||||
|
||||
for (const button of screen.getAllByRole('button').slice(0, 3)) {
|
||||
fireEvent.mouseEnter(button)
|
||||
expect(button.style.color).toBe('var(--text-primary)')
|
||||
fireEvent.mouseLeave(button)
|
||||
expect(button.style.color).toBe(button === screen.getAllByRole('button')[2] ? 'var(--text-faint)' : 'var(--text-muted)')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownPreviewModal', () => {
|
||||
const mdState = (overrides: Partial<FileManagerState> = {}) => state({
|
||||
previewFile: { id: 2, original_name: 'notes.md', url: '/uploads/files/notes.md' } as unknown as TripFile,
|
||||
previewFileUrl: '/uploads/files/notes.md?token=abc',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('FE-W4FPM-008: fetches the markdown with credentials and renders it', async () => {
|
||||
const fetchMock = vi.fn(async () => ({ ok: true, text: async () => '# Packing\n\nBring a **towel**.' }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<MarkdownPreviewModal {...mdState()} />)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/uploads/files/notes.md?token=abc', { credentials: 'include' })
|
||||
expect(await screen.findByRole('heading', { name: 'Packing' })).toBeInTheDocument()
|
||||
expect(screen.getByText('towel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4FPM-009: shows the error copy when the fetch fails', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline') }))
|
||||
|
||||
render(<MarkdownPreviewModal {...mdState()} />)
|
||||
|
||||
expect(await screen.findByText('files.openError')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4FPM-010: shows the error copy on a non-ok response', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, text: async () => '' })))
|
||||
|
||||
render(<MarkdownPreviewModal {...mdState()} />)
|
||||
|
||||
expect(await screen.findByText('files.openError')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4FPM-011: skips the fetch until a signed url exists', () => {
|
||||
const fetchMock = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
render(<MarkdownPreviewModal {...mdState({ previewFileUrl: null })} />)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('notes.md')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W4FPM-012: the header buttons open, download and close', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, text: async () => 'hi' })))
|
||||
render(<MarkdownPreviewModal {...mdState()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
|
||||
expect(openFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'files.download' }))
|
||||
expect(downloadFile).toHaveBeenCalledWith('/uploads/files/notes.md', 'notes.md')
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(setPreviewFile).toHaveBeenCalledWith(null)
|
||||
await waitFor(() => expect(screen.getByText('hi')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('FE-W4FPM-013: a failed open toasts the error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, text: async () => '' })))
|
||||
openFile.mockRejectedValue(new Error('blocked'))
|
||||
render(<MarkdownPreviewModal {...mdState()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /openTab/ }))
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('files.openError'))
|
||||
})
|
||||
})
|
||||
@@ -54,6 +54,7 @@ export function useFileManager({ files = [], onUpload, onDelete, onUpdate, place
|
||||
setShowTrash(v => !v)
|
||||
}, [showTrash, loadTrash])
|
||||
|
||||
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
|
||||
const refreshFiles = useCallback(async () => {
|
||||
if (onUpdate) onUpdate(0, {} as any)
|
||||
}, [onUpdate])
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
// FE-NOFEAR-BCN-001 to FE-NOFEAR-BCN-019
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, act, fireEvent } from '../../../tests/helpers/render'
|
||||
import NoFearBeacon from './NoFearBeacon'
|
||||
|
||||
vi.mock('./NoFearShow', () => ({
|
||||
default: ({ onClose }: { onClose: () => void }) => (
|
||||
<div role="dialog" aria-label="show stub">
|
||||
<button type="button" onClick={onClose}>close show</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const DISMISS_KEY = 'trek.fourzero.dismissed'
|
||||
const POINT_COUNT = 24
|
||||
|
||||
interface StrokeRecord {
|
||||
style: string
|
||||
width: number
|
||||
}
|
||||
|
||||
interface FakeCtx {
|
||||
strokeStyle: string
|
||||
lineWidth: number
|
||||
fillStyle: string
|
||||
globalCompositeOperation: string
|
||||
setTransform: ReturnType<typeof vi.fn>
|
||||
clearRect: ReturnType<typeof vi.fn>
|
||||
beginPath: ReturnType<typeof vi.fn>
|
||||
moveTo: ReturnType<typeof vi.fn>
|
||||
lineTo: ReturnType<typeof vi.fn>
|
||||
stroke: ReturnType<typeof vi.fn>
|
||||
arc: ReturnType<typeof vi.fn>
|
||||
fill: ReturnType<typeof vi.fn>
|
||||
strokes: StrokeRecord[]
|
||||
}
|
||||
|
||||
interface FakeObserver {
|
||||
cb: ResizeObserverCallback
|
||||
observe: ReturnType<typeof vi.fn>
|
||||
unobserve: ReturnType<typeof vi.fn>
|
||||
disconnect: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
let frames: FrameRequestCallback[] = []
|
||||
let cancelSpy: ReturnType<typeof vi.fn>
|
||||
let ctx: FakeCtx | null = null
|
||||
let observers: FakeObserver[] = []
|
||||
let randomQueue: number[] = []
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver
|
||||
|
||||
function makeCtx(): FakeCtx {
|
||||
const c: FakeCtx = {
|
||||
strokeStyle: '',
|
||||
lineWidth: 0,
|
||||
fillStyle: '',
|
||||
globalCompositeOperation: 'source-over',
|
||||
setTransform: vi.fn(),
|
||||
clearRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(() => { c.strokes.push({ style: c.strokeStyle, width: c.lineWidth }) }),
|
||||
arc: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
strokes: [],
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
observe = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
constructor(public cb: ResizeObserverCallback) { observers.push(this) }
|
||||
}
|
||||
|
||||
/** Runs the pending rAF callback with the given timestamp (ms). */
|
||||
function frame(ms: number): void {
|
||||
const pending = frames
|
||||
frames = []
|
||||
act(() => { pending.forEach(cb => cb(ms)) })
|
||||
}
|
||||
|
||||
function resetDrawCounters(): void {
|
||||
ctx?.moveTo.mockClear()
|
||||
ctx?.lineTo.mockClear()
|
||||
ctx?.arc.mockClear()
|
||||
ctx?.fill.mockClear()
|
||||
if (ctx) ctx.strokes.length = 0
|
||||
}
|
||||
|
||||
function setReducedMotion(reduce: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: reduce,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
// Well inside the release window so the beacon is not retired by the calendar.
|
||||
vi.setSystemTime(new Date('2026-08-01T09:00:00'))
|
||||
frames = []
|
||||
observers = []
|
||||
cancelSpy = vi.fn()
|
||||
ctx = makeCtx()
|
||||
randomQueue = [0]
|
||||
let randomIdx = 0
|
||||
vi.spyOn(Math, 'random').mockImplementation(() => {
|
||||
const v = randomQueue[randomIdx % randomQueue.length]
|
||||
randomIdx++
|
||||
return v
|
||||
})
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
|
||||
frames.push(cb)
|
||||
return frames.length
|
||||
}))
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
|
||||
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctx) as unknown as typeof originalGetContext
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 400 })
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
|
||||
setReducedMotion(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
|
||||
globalThis.ResizeObserver = OriginalResizeObserver
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('NoFearBeacon', () => {
|
||||
it('FE-NOFEAR-BCN-001: renders the trigger card with title, subtitle and retirement badge', () => {
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
const play = screen.getByRole('button', { name: 'Press play.' })
|
||||
expect(play).toHaveTextContent('NO FEAR')
|
||||
expect(play).toHaveTextContent('A sign for an open world.')
|
||||
expect(screen.getByText('Shown until Aug 23')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-002: renders nothing once the release window has passed', () => {
|
||||
vi.setSystemTime(new Date('2026-08-24T00:00:01'))
|
||||
|
||||
const { container } = render(<NoFearBeacon />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-003: stays hidden when it was dismissed before', () => {
|
||||
localStorage.setItem(DISMISS_KEY, '1')
|
||||
|
||||
const { container } = render(<NoFearBeacon />)
|
||||
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-004: still renders when localStorage cannot be read', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key: string) => {
|
||||
if (key === DISMISS_KEY) throw new Error('storage blocked')
|
||||
return null
|
||||
})
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-005: needs two clicks to retire the moment and persists the choice', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(dismiss).toHaveTextContent('Hide for good?')
|
||||
expect(dismiss).toHaveClass('fz-beacon-dismiss-confirm')
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
expect(localStorage.getItem(DISMISS_KEY)).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-006: disarms the confirm state when the pointer leaves', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
expect(dismiss).toHaveTextContent('Hide for good?')
|
||||
|
||||
fireEvent.mouseLeave(dismiss)
|
||||
|
||||
expect(dismiss).not.toHaveTextContent('Hide for good?')
|
||||
expect(dismiss).not.toHaveClass('fz-beacon-dismiss-confirm')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-007: disarms the confirm state on blur', () => {
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
fireEvent.blur(dismiss)
|
||||
|
||||
expect(dismiss).not.toHaveTextContent('Hide for good?')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-008: hides for the session when the choice cannot be persisted', () => {
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') })
|
||||
render(<NoFearBeacon />)
|
||||
const dismiss = screen.getByRole('button', { name: 'Dismiss' })
|
||||
|
||||
fireEvent.click(dismiss)
|
||||
fireEvent.click(dismiss)
|
||||
|
||||
expect(screen.queryByText('NO FEAR')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-009: opens the show on play and closes it again', async () => {
|
||||
render(<NoFearBeacon />)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Press play.' }))
|
||||
await act(async () => { await Promise.resolve() })
|
||||
|
||||
expect(screen.getByRole('dialog', { name: 'show stub' })).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close show' }))
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-010: sizes the teaser canvas by capped device pixel ratio', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 3 })
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')
|
||||
expect(canvas?.width).toBe(1200)
|
||||
expect(canvas?.height).toBe(600)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledWith(1.5, 0, 0, 1.5, 0, 0)
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(canvas)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-011: re-fits the canvas when the observer reports a resize', () => {
|
||||
render(<NoFearBeacon />)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => { observers[0].cb([], observers[0] as unknown as ResizeObserver) })
|
||||
|
||||
expect(ctx?.setTransform).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-012: breathes every light twice per frame and keeps animating', () => {
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
|
||||
expect(ctx?.clearRect).toHaveBeenCalledWith(0, 0, 800, 400)
|
||||
expect(ctx?.arc).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(ctx?.moveTo).not.toHaveBeenCalled()
|
||||
expect(ctx?.globalCompositeOperation).toBe('source-over')
|
||||
expect(frames).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-013: spawns a golden arc between two distant lights and grows it', () => {
|
||||
// a = point 0, first b throwaway, then a far point so the search breaks at once.
|
||||
randomQueue = [0, 0, 0.9]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
resetDrawCounters()
|
||||
|
||||
frame(2000)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
// Two passes per arc: wide warm halo, then the bright core.
|
||||
expect(ctx?.strokes).toEqual([
|
||||
{ style: 'rgba(255, 180, 95, 0.12)', width: 3.4 },
|
||||
{ style: 'rgba(255, 208, 130, 0.55)', width: 1.1 },
|
||||
])
|
||||
const partial = ctx?.lineTo.mock.calls.length ?? 0
|
||||
expect(partial).toBeGreaterThan(0)
|
||||
expect(partial).toBeLessThan(22)
|
||||
|
||||
resetDrawCounters()
|
||||
frame(3600)
|
||||
expect(ctx?.lineTo).toHaveBeenCalledTimes(22)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-014: fades an arc out and drops it once it is older than 4.5s', () => {
|
||||
randomQueue = [0, 0, 0.9]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
frame(2000)
|
||||
|
||||
// 3.75s old: half faded, drawn behind the arc that just spawned.
|
||||
resetDrawCounters()
|
||||
frame(5750)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(2)
|
||||
expect(ctx?.strokes.slice(2)).toEqual([
|
||||
{ style: 'rgba(255, 180, 95, 0.06)', width: 3.4 },
|
||||
{ style: 'rgba(255, 208, 130, 0.275)', width: 1.1 },
|
||||
])
|
||||
|
||||
// Past 4.5s it is dropped and only the younger arc remains.
|
||||
resetDrawCounters()
|
||||
frame(7000)
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-015: gives up looking for a distant partner after six tries', () => {
|
||||
// Every draw returns the same index, so no candidate is ever far enough away.
|
||||
randomQueue = [0]
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
frame(0)
|
||||
resetDrawCounters()
|
||||
frame(2000)
|
||||
|
||||
expect(ctx?.moveTo).toHaveBeenCalledTimes(1)
|
||||
expect(Math.random).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-016: paints a single frame and stops when motion is reduced', () => {
|
||||
setReducedMotion(true)
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
expect(frames).toHaveLength(1)
|
||||
|
||||
frame(0)
|
||||
|
||||
expect(ctx?.fill).toHaveBeenCalledTimes(POINT_COUNT * 2)
|
||||
expect(frames).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-017: skips the teaser entirely without a 2d context', () => {
|
||||
ctx = null
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(frames).toHaveLength(0)
|
||||
expect(observers).toHaveLength(0)
|
||||
expect(screen.getByText('NO FEAR')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-018: cancels the frame and disconnects the observer on unmount', () => {
|
||||
const { unmount } = render(<NoFearBeacon />)
|
||||
|
||||
unmount()
|
||||
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-BCN-019: falls back to a pixel ratio of 1 when the browser reports none', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
|
||||
|
||||
render(<NoFearBeacon />)
|
||||
|
||||
expect(document.querySelector<HTMLCanvasElement>('canvas.fz-beacon-canvas')?.width).toBe(800)
|
||||
expect(ctx?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { noFearChrome, noFearCopy } from './noFearLines'
|
||||
import './fourzero.css'
|
||||
|
||||
// TREK 4.0.0 release moment — remove together with the FourZero folder.
|
||||
|
||||
const NoFearShow = lazy(() => import('./NoFearShow'))
|
||||
|
||||
// The moment retires itself. Last day it shows: 2026-08-23 (local midnight after
|
||||
// it ends the run). After that the beacon renders nothing even if the folder is
|
||||
// still in the build — so self-hosters who don't update past this version still
|
||||
// see it disappear on time. The end date holds regardless of updates.
|
||||
const VISIBLE_UNTIL = new Date('2026-08-24T00:00:00').getTime()
|
||||
const DISMISS_KEY = 'trek.fourzero.dismissed'
|
||||
|
||||
// Abstract night-world for the teaser: light points loosely shaped like the
|
||||
// inhabited continents (relative coords), between which golden arcs travel.
|
||||
const TEASER_POINTS: [number, number][] = [
|
||||
[0.46, 0.28], [0.5, 0.22], [0.53, 0.3], [0.48, 0.38], [0.55, 0.42], [0.44, 0.5],
|
||||
[0.58, 0.55], [0.52, 0.62], [0.62, 0.3], [0.68, 0.4], [0.74, 0.32], [0.8, 0.45],
|
||||
[0.84, 0.58], [0.88, 0.36], [0.2, 0.3], [0.14, 0.4], [0.24, 0.52], [0.3, 0.66],
|
||||
[0.26, 0.78], [0.34, 0.35], [0.9, 0.72], [0.66, 0.68], [0.1, 0.26], [0.4, 0.72],
|
||||
]
|
||||
|
||||
interface TeaserArc { a: number; b: number; born: number }
|
||||
|
||||
/** The living background of the beacon: pulsing lights, traveling golden arcs. */
|
||||
function TeaserCanvas() {
|
||||
const ref = useRef<HTMLCanvasElement>(null)
|
||||
useEffect(() => {
|
||||
const cv = ref.current
|
||||
const c = cv?.getContext('2d')
|
||||
if (!cv || !c) return
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.5)
|
||||
const fit = () => {
|
||||
cv.width = Math.round(cv.clientWidth * dpr)
|
||||
cv.height = Math.round(cv.clientHeight * dpr)
|
||||
c.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
fit()
|
||||
const ro = new ResizeObserver(fit)
|
||||
ro.observe(cv)
|
||||
const arcs: TeaserArc[] = []
|
||||
let last = 0
|
||||
let raf = 0
|
||||
const draw = (now: number) => {
|
||||
const t = now / 1000
|
||||
const w = cv.clientWidth
|
||||
const h = cv.clientHeight
|
||||
c.clearRect(0, 0, w, h)
|
||||
// A new arc every ~1.6s between two far-apart lights.
|
||||
if (t - last > 1.6 && arcs.length < 7) {
|
||||
last = t
|
||||
const a = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
let b = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
for (let tries = 0; tries < 6; tries++) {
|
||||
b = Math.floor(Math.random() * TEASER_POINTS.length)
|
||||
if (Math.abs(TEASER_POINTS[a][0] - TEASER_POINTS[b][0]) > 0.22) break
|
||||
}
|
||||
arcs.push({ a, b, born: t })
|
||||
}
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
// Arcs: grow 1.4s, glow, fade until 4.5s.
|
||||
for (let i = arcs.length - 1; i >= 0; i--) {
|
||||
const arc = arcs[i]
|
||||
const age = t - arc.born
|
||||
if (age > 4.5) { arcs.splice(i, 1); continue }
|
||||
const grow = Math.min(age / 1.4, 1)
|
||||
const fade = age < 3 ? 1 : 1 - (age - 3) / 1.5
|
||||
const [ax, ay] = TEASER_POINTS[arc.a]
|
||||
const [bx, by] = TEASER_POINTS[arc.b]
|
||||
const x1 = ax * w; const y1 = ay * h
|
||||
const x2 = bx * w; const y2 = by * h
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2 - Math.min(Math.abs(x2 - x1) * 0.35, h * 0.34)
|
||||
c.beginPath()
|
||||
const steps = 22
|
||||
const eased = 1 - (1 - grow) ** 3
|
||||
const upto = Math.floor(steps * eased)
|
||||
const pt = (q: number): [number, number] => [
|
||||
(1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2,
|
||||
(1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2,
|
||||
]
|
||||
for (let s = 0; s <= upto; s++) {
|
||||
const [ix, iy] = pt(s / steps)
|
||||
if (s === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
}
|
||||
if (eased < 1) {
|
||||
const [tx, ty] = pt(eased)
|
||||
c.lineTo(tx, ty)
|
||||
}
|
||||
c.strokeStyle = `rgba(255, 180, 95, ${0.12 * fade})`
|
||||
c.lineWidth = 3.4
|
||||
c.stroke()
|
||||
c.strokeStyle = `rgba(255, 208, 130, ${0.55 * fade})`
|
||||
c.lineWidth = 1.1
|
||||
c.stroke()
|
||||
}
|
||||
// Lights: soft breathing points.
|
||||
for (let i = 0; i < TEASER_POINTS.length; i++) {
|
||||
const [px, py] = TEASER_POINTS[i]
|
||||
const breathe = 0.55 + 0.45 * Math.sin(t * 1.4 + i * 1.7)
|
||||
c.fillStyle = `rgba(255, 214, 150, ${0.5 * breathe})`
|
||||
c.beginPath()
|
||||
c.arc(px * w, py * h, 1.4 + breathe * 0.8, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
c.fillStyle = `rgba(255, 190, 110, ${0.1 * breathe})`
|
||||
c.beginPath()
|
||||
c.arc(px * w, py * h, 6 + breathe * 3, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
if (!reduced) raf = requestAnimationFrame(draw)
|
||||
}
|
||||
raf = requestAnimationFrame(draw)
|
||||
return () => { cancelAnimationFrame(raf); ro.disconnect() }
|
||||
}, [])
|
||||
return <canvas ref={ref} className="fz-beacon-canvas" aria-hidden />
|
||||
}
|
||||
|
||||
/**
|
||||
* The dashboard trigger for the 4.0.0 "KEINE ANGST" show: a dark cinematic card
|
||||
* with a living web of golden travel arcs (desktop only). One click starts the
|
||||
* show. Core colors ride inline so no dashboard widget CSS can wash them out.
|
||||
*/
|
||||
export default function NoFearBeacon() {
|
||||
const { language } = useTranslation()
|
||||
const copy = noFearCopy(language)
|
||||
const chrome = noFearChrome(language)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
try { return localStorage.getItem(DISMISS_KEY) === '1' } catch { return false }
|
||||
})
|
||||
|
||||
// Past the end date, or dismissed for good — nothing renders.
|
||||
if (dismissed || Date.now() >= VISIBLE_UNTIL) return null
|
||||
|
||||
const dismiss = () => {
|
||||
try { localStorage.setItem(DISMISS_KEY, '1') } catch { /* private mode — hide for this session at least */ }
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fz-beacon" style={{ background: '#07080d', color: '#f5f2ea' }}>
|
||||
<TeaserCanvas />
|
||||
<div className="fz-beacon-glow" aria-hidden />
|
||||
<button type="button" className="fz-beacon-play" aria-label={copy.beaconCta} onClick={() => setOpen(true)}>
|
||||
<span className="fz-beacon-title">{copy.beaconTitle}</span>
|
||||
<span className="fz-beacon-sub" style={{ color: 'rgba(245, 240, 225, 0.75)' }}>{copy.beaconSub}</span>
|
||||
</button>
|
||||
{/* Top-right X: two-step so a stray click can't retire the moment for good.
|
||||
Dismissing is permanent (localStorage) — only an update brings it back. */}
|
||||
<button
|
||||
type="button"
|
||||
className={`fz-beacon-dismiss ${confirming ? 'fz-beacon-dismiss-confirm' : ''}`}
|
||||
aria-label={chrome.dismiss}
|
||||
title={chrome.dismiss}
|
||||
onClick={() => (confirming ? dismiss() : setConfirming(true))}
|
||||
onMouseLeave={() => setConfirming(false)}
|
||||
onBlur={() => setConfirming(false)}
|
||||
>
|
||||
{confirming ? chrome.confirm : <X size={15} />}
|
||||
</button>
|
||||
{/* White retirement badge: viewable until Aug 23. */}
|
||||
<span className="fz-beacon-until">{chrome.until}</span>
|
||||
</div>
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<NoFearShow onClose={() => setOpen(false)} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,662 @@
|
||||
// FE-NOFEAR-SHOW-001 to FE-NOFEAR-SHOW-035
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import apiClient, { placesApi, tripsApi } from '../../api/client'
|
||||
import NoFearShow from './NoFearShow'
|
||||
|
||||
interface AudioStub {
|
||||
start: ReturnType<typeof vi.fn>
|
||||
setMuted: ReturnType<typeof vi.fn>
|
||||
resume: ReturnType<typeof vi.fn>
|
||||
setSuspended: ReturnType<typeof vi.fn>
|
||||
swell: ReturnType<typeof vi.fn>
|
||||
impact: ReturnType<typeof vi.fn>
|
||||
setAct: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface SceneStub {
|
||||
layout: ReturnType<typeof vi.fn>
|
||||
load: ReturnType<typeof vi.fn>
|
||||
setPersonalPlaces: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
interface AssemblyStub {
|
||||
init: ReturnType<typeof vi.fn>
|
||||
draw: ReturnType<typeof vi.fn>
|
||||
isReady: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
// Audio, scene and the finale's particle assembly are replaced wholesale: this
|
||||
// suite is about the React shell (acts, clock, portal, teardown), not WebAudio
|
||||
// or canvas painting, both of which have their own unit tests.
|
||||
const stubs = vi.hoisted(() => {
|
||||
const audio: AudioStub[] = []
|
||||
const scene: SceneStub[] = []
|
||||
const assembly: AssemblyStub[] = []
|
||||
class FakeAudio {
|
||||
start = vi.fn()
|
||||
setMuted = vi.fn()
|
||||
resume = vi.fn()
|
||||
setSuspended = vi.fn()
|
||||
swell = vi.fn()
|
||||
impact = vi.fn()
|
||||
setAct = vi.fn()
|
||||
dispose = vi.fn()
|
||||
constructor() { audio.push(this) }
|
||||
}
|
||||
class FakeScene {
|
||||
layout = vi.fn()
|
||||
load = vi.fn()
|
||||
setPersonalPlaces = vi.fn()
|
||||
draw = vi.fn()
|
||||
constructor() { scene.push(this) }
|
||||
}
|
||||
class FakeAssembly {
|
||||
init = vi.fn()
|
||||
draw = vi.fn()
|
||||
isReady = vi.fn()
|
||||
constructor() { assembly.push(this) }
|
||||
}
|
||||
return { audio, scene, assembly, FakeAudio, FakeScene, FakeAssembly }
|
||||
})
|
||||
|
||||
vi.mock('./noFearAudio', () => ({ NoFearAudio: stubs.FakeAudio }))
|
||||
vi.mock('./noFearScene', () => ({ NoFearScene: stubs.FakeScene }))
|
||||
vi.mock('./noFearAssembly', () => ({ TextAssembly: stubs.FakeAssembly }))
|
||||
|
||||
const LINES = {
|
||||
afraid: 'They want you to be afraid.',
|
||||
ofTheStranger: 'Afraid of the stranger. Afraid of everything you don’t know.',
|
||||
fearTool: 'Because fear closes borders — first on maps, then in minds.',
|
||||
hateTrade: 'Fear is their tool. Hatred is their trade.',
|
||||
butYouTraveled: 'But you have traveled.',
|
||||
tables: 'You have eaten at foreign tables. Slept under foreign roofs. Laughed with strangers.',
|
||||
notAnOpinion: 'Racism is not an opinion. Fascism is not an alternative.',
|
||||
everyDot: 'Every one of these lights is a table where someone was welcome.',
|
||||
yourPlaces: 'This — this was you.',
|
||||
}
|
||||
|
||||
let nowMs = 0
|
||||
let frames: FrameRequestCallback[] = []
|
||||
let cancelSpy: ReturnType<typeof vi.fn>
|
||||
let ctxStub: { setTransform: ReturnType<typeof vi.fn> } | null = null
|
||||
const originalGetContext = HTMLCanvasElement.prototype.getContext
|
||||
|
||||
const audio = () => stubs.audio[0]
|
||||
const scene = () => stubs.scene[0]
|
||||
|
||||
/** Runs the pending rAF callback at the given show time (seconds). */
|
||||
function frame(seconds: number): void {
|
||||
nowMs = seconds * 1000
|
||||
const pending = frames
|
||||
frames = []
|
||||
act(() => { pending.forEach(cb => cb(nowMs)) })
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 12; i++) await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
function canvas(): HTMLCanvasElement {
|
||||
const el = document.querySelector<HTMLCanvasElement>('canvas.fz-canvas')
|
||||
if (!el) throw new Error('show canvas missing')
|
||||
return el
|
||||
}
|
||||
|
||||
/** The scene state handed to the canvas on the most recent frame. */
|
||||
function lastSceneState(): { opacity: number; particles: number } {
|
||||
const calls = scene().draw.mock.calls
|
||||
return calls[calls.length - 1][1] as { opacity: number; particles: number }
|
||||
}
|
||||
|
||||
/** Visible line text, normalised across the sentence-by-sentence reveal. */
|
||||
function lineText(): string {
|
||||
const el = document.querySelector('.fz-line:not(.fz-line-ghost)')
|
||||
return (el?.textContent ?? '').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function setReducedMotion(reduce: boolean): void {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: reduce,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
nowMs = 0
|
||||
frames = []
|
||||
stubs.audio.length = 0
|
||||
stubs.scene.length = 0
|
||||
stubs.assembly.length = 0
|
||||
cancelSpy = vi.fn()
|
||||
ctxStub = { setTransform: vi.fn() }
|
||||
vi.spyOn(performance, 'now').mockImplementation(() => nowMs)
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => {
|
||||
frames.push(cb)
|
||||
return frames.length
|
||||
}))
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelSpy)
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn(() => ctxStub) as unknown as typeof originalGetContext
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientWidth', { configurable: true, get: () => 800 })
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'clientHeight', { configurable: true, get: () => 600 })
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 1 })
|
||||
setReducedMotion(false)
|
||||
// No traveler data by default — the generic show.
|
||||
vi.spyOn(tripsApi, 'list').mockResolvedValue([])
|
||||
vi.spyOn(placesApi, 'list').mockResolvedValue([])
|
||||
vi.spyOn(apiClient, 'get').mockRejectedValue(new Error('no atlas'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = originalGetContext
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientWidth')
|
||||
Reflect.deleteProperty(HTMLCanvasElement.prototype, 'clientHeight')
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
document.body.classList.remove('fz-show-open')
|
||||
})
|
||||
|
||||
describe('NoFearShow', () => {
|
||||
it('FE-NOFEAR-SHOW-001: portals a labelled dialog into the body and locks the page chrome', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'NO FEAR' })
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
expect(dialog.parentElement).toBe(document.body)
|
||||
expect(document.body).toHaveClass('fz-show-open')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-002: boots audio and scene and starts the soundtrack', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(stubs.audio).toHaveLength(1)
|
||||
expect(stubs.scene).toHaveLength(1)
|
||||
expect(audio().start).toHaveBeenCalledTimes(1)
|
||||
expect(scene().layout).toHaveBeenCalledWith(800, 600)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-003: sizes the canvas by capped device pixel ratio', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 4 })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(canvas().width).toBe(1400)
|
||||
expect(canvas().height).toBe(1050)
|
||||
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1.75, 0, 0, 1.75, 0, 0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-004: loads the scene with a live abort signal that aborts on unmount', () => {
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
const signal = scene().load.mock.calls[0][2] as AbortSignal
|
||||
expect(scene().load).toHaveBeenCalledWith(800, 600, signal)
|
||||
expect(signal.aborted).toBe(false)
|
||||
|
||||
unmount()
|
||||
expect(signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-005: coalesces a resize storm into a single re-fit', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
expect(scene().layout).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 20; i++) window.dispatchEvent(new Event('resize'))
|
||||
})
|
||||
// layout() rebakes every static layer, so nothing happens while the drag runs
|
||||
expect(scene().layout).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
|
||||
expect(scene().layout).toHaveBeenCalledTimes(2)
|
||||
expect(scene().layout).toHaveBeenLastCalledWith(800, 600)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-006: shows no line before the first cue', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.2)
|
||||
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
expect(audio().setAct).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-007: opens on the fear act with the first line', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.9)
|
||||
|
||||
expect(lineText()).toBe(LINES.afraid)
|
||||
expect(audio().setAct).toHaveBeenCalledWith('fear')
|
||||
expect(scene().draw).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-008: decays the replaced fear line into a per-character ghost', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(0.9)
|
||||
expect(document.querySelector('.fz-line-ghost')).toBeNull()
|
||||
|
||||
frame(6.1)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
const ghost = document.querySelector('.fz-line-ghost')
|
||||
expect(ghost).not.toBeNull()
|
||||
expect(ghost?.querySelectorAll('.fz-char-decay')).toHaveLength(LINES.afraid.length)
|
||||
|
||||
// Once its letters have decayed the ghost retires itself.
|
||||
act(() => { vi.advanceTimersByTime(1400) })
|
||||
expect(document.querySelector('.fz-line-ghost')).toBeNull()
|
||||
|
||||
frame(12.1)
|
||||
const ghostText = document.querySelector('.fz-line-ghost')?.textContent ?? ''
|
||||
expect(ghostText.replace(/\u00a0/g, ' ')).toBe(LINES.ofTheStranger)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-009: raises the red vignette while the borders burn', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(12.1)
|
||||
expect(lineText()).toBe(LINES.fearTool)
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('dread')
|
||||
expect(document.querySelector('.fz-vignette')).not.toBeNull()
|
||||
|
||||
frame(18.6)
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
expect(document.querySelector('.fz-vignette')).not.toBeNull()
|
||||
|
||||
frame(26.1)
|
||||
expect(document.querySelector('.fz-vignette')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-010: strobes the staccato words and hides the line inside a flash window', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(18.6)
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
|
||||
frame(22.5)
|
||||
expect(screen.getByText('FEAR.')).toBeInTheDocument()
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
|
||||
frame(23.8)
|
||||
expect(screen.getByText('HATRED.')).toBeInTheDocument()
|
||||
|
||||
frame(25.2)
|
||||
expect(screen.getByText('WALLS.')).toBeInTheDocument()
|
||||
|
||||
frame(25.8)
|
||||
expect(document.querySelector('.fz-flash')).toBeNull()
|
||||
expect(lineText()).toBe(LINES.hateTrade)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-011: cuts to silence with no line at all', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(18.6)
|
||||
frame(26.2)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('silence')
|
||||
expect(document.querySelector('.fz-line:not(.fz-line-ghost)')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-012: swells into the soft pivot line', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(26.2)
|
||||
frame(28.6)
|
||||
|
||||
expect(lineText()).toBe(LINES.butYouTraveled)
|
||||
expect(document.querySelector('.fz-line-soft')).not.toBeNull()
|
||||
expect(audio().swell).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-013: reveals hope-act lines sentence by sentence', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(34.1)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('hope')
|
||||
expect(lineText()).toBe(LINES.tables)
|
||||
const segments = document.querySelectorAll('.fz-seg')
|
||||
expect(segments).toHaveLength(3)
|
||||
expect(segments[0].textContent?.trim()).toBe('You have eaten at foreign tables.')
|
||||
expect((segments[2] as HTMLElement).style.animationDelay).toBe('1.9s')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-014: renders the hard line unsegmented and punches the audio', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(34.1)
|
||||
frame(64.1)
|
||||
|
||||
expect(lineText()).toBe(LINES.notAnOpinion)
|
||||
expect(document.querySelector('.fz-line-hard')).not.toBeNull()
|
||||
expect(document.querySelectorAll('.fz-seg')).toHaveLength(0)
|
||||
expect(audio().impact).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-015: falls back to the generic line when the traveler has no data', async () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(55.6)
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(audio().impact).toHaveBeenCalledWith(0.35)
|
||||
|
||||
frame(58.9)
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-016: feeds the traveler places into the scene and states the country count', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
{ lat: null, lng: 9.9 },
|
||||
])
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 9 } } })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).toHaveBeenCalledWith([
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
{ lat: 48.1, lng: 11.5 },
|
||||
{ lat: 52.5, lng: 13.4 },
|
||||
])
|
||||
|
||||
frame(55.6)
|
||||
expect(lineText()).toBe(LINES.yourPlaces)
|
||||
|
||||
frame(58.9)
|
||||
expect(lineText()).toBe('4 places. 9 countries. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-017: falls back to trip counts when Atlas reports no countries', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }, { id: 3 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([{ lat: 1, lng: 2 }])
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { stats: { totalCountries: 0 } } })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe('3 places. 3 journeys. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-018: falls back to trip counts when the Atlas request fails', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 7 }])
|
||||
vi.mocked(placesApi.list).mockResolvedValue([
|
||||
{ lat: 1, lng: 2 },
|
||||
{ lat: 3, lng: 4 },
|
||||
{ lat: 5, lng: 6 },
|
||||
])
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe('3 places. 1 journeys. And not once did the world hurt you.')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-019: ignores a trip whose places fail to load and skips thin data sets', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }, { id: 2 }])
|
||||
vi.mocked(placesApi.list)
|
||||
.mockResolvedValueOnce([{ lat: 1, lng: 2 }])
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-020: survives a failing trip list', async () => {
|
||||
vi.mocked(tripsApi.list).mockRejectedValue(new Error('offline'))
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
frame(58.9)
|
||||
|
||||
expect(lineText()).toBe(LINES.everyDot)
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-021: ignores a non-array trip response', async () => {
|
||||
vi.mocked(tripsApi.list).mockResolvedValue({ trips: [] })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
|
||||
expect(placesApi.list).not.toHaveBeenCalled()
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-022: drops late place results after unmount', async () => {
|
||||
let releasePlaces: (value: { lat: number; lng: number }[]) => void = () => {}
|
||||
vi.mocked(tripsApi.list).mockResolvedValue([{ id: 1 }])
|
||||
vi.mocked(placesApi.list).mockReturnValue(new Promise(resolve => { releasePlaces = resolve }))
|
||||
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
await flush()
|
||||
unmount()
|
||||
releasePlaces([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }, { lat: 5, lng: 6 }])
|
||||
await flush()
|
||||
|
||||
expect(scene().setPersonalPlaces).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-023: pauses the clock while the tab is hidden', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(10)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true })
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(audio().setSuspended).toHaveBeenCalledWith(true)
|
||||
|
||||
nowMs = 30_000
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false })
|
||||
act(() => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(audio().setSuspended).toHaveBeenLastCalledWith(false)
|
||||
|
||||
// 21s of wall clock passed, but the show only advanced 1s.
|
||||
frame(31)
|
||||
expect(lineText()).toBe(LINES.ofTheStranger)
|
||||
|
||||
Reflect.deleteProperty(document, 'hidden')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-024: resumes audio on any pointer gesture', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
act(() => { window.dispatchEvent(new Event('pointerdown')) })
|
||||
|
||||
expect(audio().resume).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-025: closes on Escape and ignores other keys', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NoFearShow onClose={onClose} />)
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
act(() => { window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-026: closes through the chrome button', () => {
|
||||
const onClose = vi.fn()
|
||||
render(<NoFearShow onClose={onClose} />)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Carry it on' }).click() })
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-027: toggles mute and relabels the button', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Sound off' }).click() })
|
||||
expect(audio().setMuted).toHaveBeenCalledWith(true)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Sound on' }).click() })
|
||||
expect(audio().setMuted).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-028: skip jumps straight to the anthem and retires the skip button', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(5)
|
||||
expect(lineText()).toBe(LINES.afraid)
|
||||
|
||||
act(() => { screen.getByRole('button', { name: 'Skip' }).click() })
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('NO FEAR')
|
||||
expect(screen.queryByRole('button', { name: 'Skip' })).toBeNull()
|
||||
expect(document.querySelector('.fz-line')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-029: closes the show with the anthem cascade in every other language', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(71.2)
|
||||
|
||||
expect(audio().setAct).toHaveBeenLastCalledWith('anthem')
|
||||
expect(audio().impact).toHaveBeenLastCalledWith(0.8)
|
||||
const cascade = document.querySelectorAll('.fz-cascade-item')
|
||||
expect(cascade).toHaveLength(22)
|
||||
expect(cascade[0].textContent).toBe('Keine Angst')
|
||||
expect(screen.queryByText('No fear')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-030: condenses the anthem title out of one lazily built particle assembly', () => {
|
||||
vi.spyOn(HTMLHeadingElement.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||
left: 100, top: 200, width: 300, height: 60,
|
||||
} as unknown as DOMRect)
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
// The title only exists from the frame after the anthem state flipped.
|
||||
frame(71.2)
|
||||
expect(stubs.assembly).toHaveLength(0)
|
||||
|
||||
frame(72)
|
||||
expect(stubs.assembly).toHaveLength(1)
|
||||
expect(stubs.assembly[0].init).toHaveBeenCalledWith(
|
||||
'NO FEAR',
|
||||
expect.any(String),
|
||||
{ left: 100, top: 200, width: 300, height: 60 },
|
||||
800,
|
||||
600,
|
||||
)
|
||||
const [drawCtx, progress, fade, drawT] = stubs.assembly[0].draw.mock.calls[0] as [unknown, number, number, number]
|
||||
expect(drawCtx).toBe(ctxStub)
|
||||
expect(progress).toBeCloseTo(0.1875, 6)
|
||||
expect(fade).toBe(0)
|
||||
expect(drawT).toBe(72)
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveClass('fz-word-hidden')
|
||||
|
||||
frame(76)
|
||||
expect(stubs.assembly).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-hidden')
|
||||
|
||||
// Targets are absolute screen coordinates, so a resize invalidates them and
|
||||
// the next frame re-samples the moved title.
|
||||
act(() => { window.dispatchEvent(new Event('resize')) })
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
frame(77)
|
||||
expect(stubs.assembly).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-031: tears down chrome, listeners and audio on unmount', () => {
|
||||
const { unmount } = render(<NoFearShow onClose={vi.fn()} />)
|
||||
const onClose = vi.fn()
|
||||
const layoutCalls = scene().layout.mock.calls.length
|
||||
|
||||
unmount()
|
||||
|
||||
expect(document.body).not.toHaveClass('fz-show-open')
|
||||
expect(audio().dispose).toHaveBeenCalledTimes(1)
|
||||
expect(cancelSpy).toHaveBeenCalled()
|
||||
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
window.dispatchEvent(new Event('pointerdown'))
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||
|
||||
expect(scene().layout.mock.calls.length).toBe(layoutCalls)
|
||||
expect(audio().resume).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-032: reduced motion renders the finale as one static frame without audio', () => {
|
||||
setReducedMotion(true)
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(audio().start).not.toHaveBeenCalled()
|
||||
expect(audio().setAct).toHaveBeenCalledWith('anthem')
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled()
|
||||
expect(scene().draw).toHaveBeenCalledTimes(1)
|
||||
const [, state, t] = scene().draw.mock.calls[0] as [unknown, { particles: number; opacity: number }, number]
|
||||
expect(state.particles).toBe(0)
|
||||
expect(state.opacity).toBe(1)
|
||||
expect(t).toBe(79)
|
||||
expect(screen.getByRole('heading', { level: 1 })).not.toHaveClass('fz-word-assembled')
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-033: still runs the show when the canvas has no 2d context', () => {
|
||||
ctxStub = null
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
frame(12.1)
|
||||
|
||||
expect(lineText()).toBe(LINES.fearTool)
|
||||
expect(scene().draw).not.toHaveBeenCalled()
|
||||
expect(stubs.assembly).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-034: dims the world through the blackout and brings it back with the hope act', () => {
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
frame(20)
|
||||
expect(lastSceneState().opacity).toBe(1)
|
||||
|
||||
frame(27)
|
||||
expect(lastSceneState().opacity).toBe(0)
|
||||
|
||||
frame(31)
|
||||
expect(lastSceneState().opacity).toBeGreaterThan(0.8)
|
||||
expect(lastSceneState().opacity).toBeLessThan(1)
|
||||
|
||||
frame(32)
|
||||
expect(lastSceneState().opacity).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-NOFEAR-SHOW-035: falls back to a pixel ratio of 1 when the browser reports none', () => {
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, writable: true, value: 0 })
|
||||
|
||||
render(<NoFearShow onClose={vi.fn()} />)
|
||||
|
||||
expect(canvas().width).toBe(800)
|
||||
expect(ctxStub?.setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user