Compare commits

...

234 Commits

Author SHA1 Message Date
Konstantinos Thermos 97a1e0ee20 fix(admin): stop a stale base URL from hijacking the Anthropic endpoint
The AI Parsing admin panel kept the Base URL in state after switching provider
and saved it unconditionally, so moving a configured Local/OpenAI provider to
Anthropic left the old host in the stored config and misrouted every Anthropic
request away from api.anthropic.com. Clear baseUrl on save when the provider is
Anthropic, matching the per-user LlmConnectionSection behaviour.

Fixes #1748
2026-08-04 13:58:26 +03:00
Maurice 446bbdc38f fix(client): stop shared items counting towards the recipient bag weight
An item shared through the sharing tier is visible to its recipients but
carried by its owner. The bag totals filtered on bag_id alone, so every
recipient saw the owner's weight added to their own load — which is exactly
the situation that tier exists for (one person brings the first-aid kit).

Filtering by owner_id, as the issue suggests, would have broken more than it
fixed: owner_id is stamped on every item including Common ones, so the group
pool would have shrunk to "only what I entered myself" even on trips where
nobody shares anything. The predicate keeps the Common pool intact and drops
only private items owned by someone else.

Closes #1767
2026-08-03 21:59:34 +02:00
Maurice d7d7fac339 feat(i18n): translate the bag weight limit strings 2026-08-03 21:40:02 +02:00
Maurice e25a1276dc feat(client): let people actually type a bag weight limit
The column, the contract, the API route and its tests have all been there
since v2.9.0 — there was simply no field to enter a limit in, so the only
way to record one was to put it in the bag name, where nothing can act on
it. Both the desktop bag card and the mobile bag row now take a limit in
kilograms (how airlines state them) and store it in grams. Emptying the
field clears the limit instead of writing a zero.

While adding it: the fill-bar formula existed in three places, and the one
in the bag modal had been copied without the limit, so a limit set on a
window narrower than xl did nothing. It now lives once in
packingListPanel.helpers as bagFillPct, next to itemWeight.
2026-08-03 21:40:02 +02:00
Maurice 628f4e78cd fix(server): let a copied packing item keep its weight
createItem never accepted weight_grams or bag_id — not in its signature, not
in the INSERT — so cloneItem had nothing to pass on and every copy arrived
empty. For a group trip that meant re-entering every weight by hand for each
traveller, which is what people were doing.

The bag needs more care than the weight. Bags belong to a trip, but they can
be assigned to a person, so inheriting the original bag would drop your copy
into somebody else luggage and inflate their total. A copy keeps the bag only
when nobody owns it or when the copier is one of its members.
2026-08-03 21:40:02 +02:00
Maurice 4e090411fe fix(server): stop a failed notification import from crashing the run
The fire-and-forget notification sends guard the send() call but not the
dynamic import around it, so a rejected import escapes as an unhandled
rejection. In CI that surfaced as an EnvironmentTeardownError from vacay
after the suite had already passed, failing the run with every test green.

trips, packing and collections already catch on the import chain; collab and
the two vacay sends were missing it.
2026-08-03 20:21:18 +02:00
Maurice 8ab50dcbbb docs(wiki): explain visited versus planned countries in the atlas
The page still described the old behaviour, where every trip counted as a
visit regardless of its dates.
2026-08-03 20:21:18 +02:00
Maurice 6019b3ed4e feat(client): carry the planned/visited split into the mobile atlas
The mobile screen shares useAtlas, so it inherits the switch state; it gets
its own compact toggle next to the bucket-list button. Search now labels a
planned country as such instead of calling it visited, the stats card hangs
the planned count off the country total, and the suggestion list keeps
places you have actually been at the top.
2026-08-03 20:21:18 +02:00
Maurice 558f348881 feat(client): let the atlas hide countries you have not reached yet
Planned countries are off the map by default and come back through a switch
above the globe, drawn dashed so they never read as somewhere you have been.
The switch only appears once there is something planned to show, and it
remembers its state.

Three things that needed care:

The colour palette is built from the visited countries alone. It assigns
colours by index, so letting planned ones join would reshuffle the whole map
every time the switch is flipped.

countryMap decides both the tooltip and what a click does, so it is built
from the visible countries — a hidden country has to fall through to the
mark-as-visited branch or it becomes unclickable.

The region layer read `data` without listing it as a dependency, which meant
region colours already went stale on their own; adding the switch would have
made that visible.

Five copies of the optimistic mark-as-visited update have moved into
withCountryMarkedVisited, which also promotes a planned country instead of
adding it twice. Antarctica joins the continent row when someone has been.
2026-08-03 20:21:18 +02:00
Maurice 97f6a43333 feat(i18n): translate the planned-countries strings
Also adds atlas.antarctica, which CONTINENT_MAP has always known about but
no locale ever had a word for.
2026-08-03 20:21:18 +02:00
Maurice d9778ed7c0 fix(server): keep the passport card in step with the atlas
getTravelStats derives its countries independently of the atlas service, so
without the same date filter the dashboard would have kept counting trips
the map no longer treats as visited — the drift #1490 closed.

Manually marked countries and the hidden-country tombstones are untouched.
2026-08-03 20:21:18 +02:00
Maurice 269bd9140f feat(server): count only started trips as visited in the atlas
Every country now carries the status of the strongest source it came from:
a country reached by both a past and a future trip is visited, one that only
appears in upcoming trips is planned. Marking a country by hand still wins
over everything, since that is a statement of fact rather than a date.

totalCountries drops to the visited ones and totalCountriesPlanned sits
beside it; continents and mostVisited follow the same rule. countries[]
stays a superset of what it returned before, so an older client keeps
painting the map exactly as it did.

The endpoint query has to select trip_id to know which trip a leg belongs
to, which makes its DISTINCT per trip — the coordinates are collapsed again
in JS so the point-in-polygon lookup still runs once per location.
2026-08-03 20:21:18 +02:00
Maurice ddba45ab3e feat(shared): tell visited countries apart from planned ones
The atlas counted every trip as a visit, so a country you had merely booked
a flight to was stamped the same as one you came back from.

tripVisitStatus buckets a trip by its dates: already started (or running)
counts as visited, starting in the future is planned, and a trip with no
dates at all is an idea that must not reach the visit stats. The comparison
is UTC, matching the dashboard and journey lifecycles.

Requested in discussion #1048.
2026-08-03 20:21:18 +02:00
jubnl c42aea4171 Merge remote-tracking branch 'origin/dev' into dev 2026-08-02 15:52:22 +02:00
jubnl 414d967223 docs: regenerate migration-graph after the admin fold
Records the fold and corrects two long-carried errors: there is no admin MCP
surface (the '11 MCP consumers' figure predated the Phase-0 addons extraction)
and systemNotices/conditions.ts was never a consumer. The memories/admin edge is
documented in the right direction for the first time. journeyService and
oauthService now head the frontier.
2026-08-02 15:08:02 +02:00
jubnl 2ac6c76086 chore: move admin backend to proper DI
The 851-line services/adminService folds into the wrapper AdminService over
DatabaseService plus injected Settings/Addons/Passkey/Packing/Auth/Permissions/
Notifications services; the auth, notifications and permissions bridge imports
all become injections, PERMISSION_ACTIONS stays a plain const, and the
mcp/sessionManager deep import keeps its anti-cycle comment. Ahead of the fold
the 11 packing-template functions move to PackingService, which already owned all
three template tables — resolving the admin-2 residual with no bridge. The pure
and module-scoped half moves to admin.helpers.ts, the version cache staying
module-scoped so admin.bridge's instance and the container singleton share one
GitHub fetch. A 1-export admin.bridge.ts serves scheduler.ts's cron, the only
out-of-container consumer. Recipe steps 3 and 4 are no-ops: no src/mcp/tools/
admin.ts has ever existed and the plugin host never imported the domain.

Four lines are non-verbatim, all path re-anchoring one directory deeper, both
resolved paths verified against the emitted dist layout.

admin.dto.ts clears all twenty AdminController allow-list entries, retiring the
three 'enabled must be a boolean' checks plus 'permissions object required' and
'Object body required' for the pipe envelope. The module e2e goes DI-native: the
3-method whole-module mock dies, 6 cases become 15 over real SQL.

Quirk fixes on top, each pinned: the three places toggles go fail-closed with an
append-only backfill migration; the template item routes honour :templateId;
updateUser rejects an empty username/email; createInvite 404s an unresolvable
trip_id; listOAuthSessions guards its scopes parse; GitHub fetches gain a timeout
and size cap and failures cache briefly; updateOidcSettings/updateAddon/updateUser
become transactional; admin.oauth_session.revoke is renamed to match its
siblings; two mutations gain their missing audit rows; getAuditLog drops the
_parse_error sentinel.

Tests move with IDs preserved (ADMIN-SVC-001..069 incl. the pre-existing 029/030
gap and duplicated 069, VNOTIF-001..007), plus ADMIN-BR-001 and ADMIN-SVC-070..076.
2026-08-02 15:07:55 +02:00
jubnl ee13b6325b feat(shared): extend the admin request contracts
Twelve schemas covering every AdminController body, replacing the four that were
imported nowhere. Deliberately permissive wherever AdminService already owns a
bespoke 400 ('Invalid role', 'Name is required', 'Username, email and password
are required'), so those errors survive byte-identically: role is z.string() not
an enum, template name and the user-create fields stay optional. savePermissions
keeps z.unknown() values so unknown levels still land in the 200 response's
skipped list; addon config and the notification-preference matrix stay records so
per-addon keys and plugin channels are not stripped; devTestNotification's inApp
accepts the object form the dev panel actually sends.
2026-08-02 15:07:23 +02:00
Johann Bauer ad7d81fd00 fix(i18n): use mode-agnostic German departure translation (#1765)
'Abflug'/'Abflugzeit' are flight-specific, but the reservations.departureDate/
departureTime keys are shared across all transport types in TransportModal
(flight and train waypoints). Switched to 'Abreise'/'Abreisezeit', which read
naturally for any mode of transport.
2026-08-02 14:44:15 +02:00
jubnl aec2d8cd87 docs: regenerate migration-graph after the notifications fold
Import-scan regeneration: notificationService and the folded
inAppNotifications leave the graph, the notifications cluster is consumed as
plain infra by the DI service, and the unblock cascade over-delivered —
repointing memories' sends erased that cluster's last domain edge, so BOTH
adminService and journeyService join the ready frontier (adminService heads
the order). Adds the dated correction bullet and the 'Quirks fixed after the
notifications fold' section (fixed vs deliberately-preserved).
2026-08-02 11:36:46 +02:00
jubnl 00447c9043 feat(server): validate notification request bodies
notifications.dto.ts wraps the @trek/shared request schemas via createZodDto
and types all five @Body() params; the five NotificationsController entries
leave the body-contract allow-list (ratchet gate enforced). Contract catch:
the client sends server/token: null on test-ntfy to mean 'use the saved
value', so testNtfyRequestSchema gains .nullable() there (spec pinned). The
inline response-enum and url-type checks die — malformed bodies now get the
pipe's standard envelope; valid bodies behave byte-identically. Two
integration cases pinning pre-ratchet tolerances (flat notify_* preferences
body, body-less test-smtp POST) update to the current wire contract.
2026-08-02 11:36:36 +02:00
jubnl d203d8cccd chore: move notifications backend to proper DI
The send() dispatcher (services/notificationService.ts) and the in-app store
(services/inAppNotifications.ts) fold together into the DI-native
NotificationsService over DatabaseService + RealtimeService; the prefs
matrix, transports, channel registry and inAppNotificationActions stay plain
infra modules. A 1-export notifications.bridge covers scheduler, legacy
adminService/memories and the six lazy fire-and-forget sends; AdminController
and the plugin RPC host (24th factory dep) inject. The 5-tool registrar +
notifications-in-app resource move to notifications.mcp.ts. Tests move with
case IDs preserved (NSVC-*, INOTIF-*, + NSVC-020 bridge pin); ~18 suites
repoint path mocks/warm-ups; the module e2e goes DI-native.

Also carries the verified-quirk fixes that would normally trail as a
separate fix(server) commit (file-overlap with the fold): toUtcIso on all
three created_at paths (INOTIF-013/014), respond claims the response CAS
before running the action handler with release-on-failure (INOTIF-011/012),
synology's fire-and-forget send gains .catch, the dispatch-failure log
unwraps Error messages (NSVC-021), and the MCP resource description drops
its false 'unread first' claim.
2026-08-02 11:36:06 +02:00
jubnl c814653f4e fix(server): tidy passkey quirks after the DI fold 2026-08-02 10:28:22 +02:00
jubnl 74be789f22 docs: regenerate migration-graph after the passkey fold 2026-08-02 10:20:38 +02:00
jubnl 5d4b2abd26 feat(server): validate passkey request bodies 2026-08-02 10:16:10 +02:00
jubnl e65f851446 chore: move passkey backend to proper DI 2026-08-02 10:10:33 +02:00
jubnl f40c66da6b fix(server): tidy oidc quirks after the DI fold 2026-08-01 18:36:07 +02:00
jubnl 5da98d0fb9 docs: regenerate migration-graph after the oidc fold 2026-08-01 18:25:51 +02:00
jubnl ec0709741a chore: move oidc backend to proper DI 2026-08-01 18:25:28 +02:00
jubnl d892b61a59 fix(server): tidy auth quirks after the DI fold
Verified defects the relocation carried byte-for-byte, each pinned by a
regression test (AUTH-DB-089..093):

- getTravelStats kept dropping lat/lng of exactly 0 (equator / prime
  meridian) through its falsy check — now explicit != null.
- registerUser's INSERT user -> invite used_count bump -> joinTripAsMember
  ran statement-by-statement; the whole signup now runs in
  db.transaction() so a mid-sequence throw rolls everything back.
- verifyMfaLogin's backup-code burn and last_login/login_count bump now
  commit as one atomic pair.
- deleteMcpToken's revokeUserSessions call is best-effort (try/catch),
  matching the changePassword/resetPassword revocations.
- updateApiKeys' current! non-null assertions became current?. ?? null —
  a user row deleted mid-request degrades to a 0-row UPDATE, not a
  TypeError/500.

Deliberately preserved (documented in migration-graph.md): the two
divergent "Password authentication is disabled" strings and enableMfa's
15-min pending-secret TTL after a wrong code.
2026-08-01 17:46:54 +02:00
jubnl 9bc31e6a1c docs: regenerate migration-graph after the auth fold
Re-ran the three-pattern import scan over server/src: authService row
deleted, thirteen helper rows updated (nine lose their last legacy
importer), oidcService/passkeyService join the ready frontier as
auth.bridge repoints, adminService is blocked on notificationService
alone, atlas.bridge's death recorded, dependency-honest order step 5
ticked, a post-auth-fold corrections entry added (bridge tax held exactly;
the reverse cycle-break, the in-container module fan-out and path-mock rot
are now tracked shapes), and the preserved auth quirks listed as trailing
fix(server) candidates.
2026-08-01 17:40:25 +02:00
jubnl fb87202683 feat(server): validate auth request bodies
DTO ratchet for the auth domain (collections sibling-commit precedent):

- shared/src/auth/auth.schema.ts gains the six missing request schemas —
  mapsKey (nullable: the client clears with an explicit null), apiKeys and
  settings (per-key partials, nullable key values), appSettings (open
  record — the server's ADMIN_SETTINGS_KEYS allow-list and its bespoke
  lockout/self-MFA 400s stay service-owned), mfaDisable, resourceToken
  (the 'Invalid purpose' 400 stays service-owned) — plus spec cases.
- New nest/auth/auth.dto.ts createZodDto wrappers; all 14 raw @Body()
  params typed (9 AuthController + 5 AuthPublicController), validated by
  the global ZodValidationPipe.
- All 14 auth entries removed from body-contract-allow-list.ts
  (PasskeyController's 4 stay until passkeyService migrates).
- auth.e2e mirrors the production pipe and pins the { error: 'field: ...' }
  envelope on a shapeless login body; auth.controller.ts drops its
  duplicated avatarDir const in favour of the auth.helpers export.
2026-08-01 17:40:16 +02:00
jubnl 45564b0a8a chore: move auth backend to proper DI
Fold the 1497-line services/authService.ts into the DI-native nest/auth
domain (Wave-5 chain opener, unblocked by the atlas fold):

- Pure crypto half (backup-code hash/match/generate, stripUserForClient,
  key masking, the import-time DUMMY_PASSWORD_HASH timing equaliser and
  avatarDir mkdir — documented parity exceptions) moves verbatim to the
  plain module nest/auth/auth.helpers.ts.
- DB half folds into AuthService over DatabaseService + injected
  PermissionsService (ex permissions.bridge) + AtlasService (ex
  atlas.bridge, deleted on schedule). mfaSetupPending and the per-email
  reset throttle stay module-scoped so the bridge and container instances
  share them; avatarDir and require('../../../package.json') re-anchor one
  directory deeper — with the two injection swaps, the only non-verbatim
  lines. Controller-facing method surface unchanged.
- New 8-export auth.bridge.ts for out-of-container consumers: mcp/index.ts
  token verification, the journey/notifications/transports registrars'
  isDemoUser, and the still-legacy adminService/oidcService/passkeyService.
- 15 domain *.mcp.ts demo guards now inject AuthService (their modules
  import AuthModule); atlas.mcp.ts alone stays on the bridge because
  AuthService injects AtlasService and the reverse module edge would close
  an AuthModule<->AtlasModule cycle (places.mcp precedent). OidcService and
  PasskeyEnabledGuard inject AuthService too.
- Tests move with case IDs preserved: authService.test.ts ->
  auth.helpers.test.ts, authServiceDb.test.ts -> auth.service.test.ts
  (+ AUTH-BR-001..007 bridge delegation, + AUTH-DB-050..088 pinning the
  previously untested getAppConfig/login/MFA/reset/admin-settings branches
  now under the src/nest coverage gate). auth.e2e converts DI-native (real
  bcrypt, real audit rows); oidc.e2e swaps its dead path mock for an
  instance spy; websocket/integration suites repoint to bridge/helpers.

Quirks preserved byte-for-byte (0-coord drop in getTravelStats,
un-transactioned registerUser/verifyMfaLogin multi-writes, unguarded
revokeUserSessions in deleteMcpToken, current! assertions in
updateApiKeys) — fixes land separately.
2026-08-01 17:39:59 +02:00
jubnl 464fe5ff77 docs: regenerate migration-graph after the atlas fold 2026-08-01 15:50:46 +02:00
jubnl ae3ba18b9e chore: move atlas backend to proper DI
Fold the 1612-line services/atlasService.ts into the DI-native AtlasService:
the stats aggregation, visited countries/regions with the #1490 tombstone and
cascade logic, and the bucket-list CRUD become methods over the injected
DatabaseService. The ~750-line pure-geo half — the bundled admin0/admin1
boundary stores with their #1576 streaming builders, the point-in-polygon
indexes, Nominatim geocoding with its shared throttle, and every module-scoped
cache including the import-time unref'd cleanup interval — moves verbatim to
the plain module atlas-geo.ts so the caches stay process-global across the
container instance, the bridge instance and test helpers (assetPath
re-anchored one directory deeper, the only non-verbatim line).

The 10-tool mcp/tools/atlas.ts registrar plus all four atlas resources in
mcp/resources.ts move onto the decorator registry as atlas.mcp.ts — the when:
atlas-addon gate is parity here (legacy tools and resources both gated; the
REST controller deliberately does not). resources.test.ts retires with its
last two cases, which move to tools-atlas-expanded.test.ts. The plugin RPC
host swaps its 9 atlas imports for the injected AtlasService (23rd constructor
dep); a minimal 2-export atlas.bridge.ts serves the one legacy consumer,
authService.getTravelStats, and dies when authService migrates.

Adopts atlas.dto.ts over the existing shared schemas and clears all three
AtlasController entries from body-contract-allow-list.ts: the hand-rolled
'name and country_code are required' 400 becomes the ZodValidationPipe
envelope (todo/places trade) while the whitespace-only bucket name keeps its
legacy 'Name is required' trim guard.

Fixes the verified defects the relocation had faithfully carried, each with a
regression test (ATLAS-SVC-031..036 plus two MCP casing cases): the four
multi-statement mark/unmark writes now run in db.transaction() (the region
cascade nests as a savepoint), the trip-less countryPlaces early return
honours manually_marked, the '|| null' bindings that dropped lat/lng 0 and
empty-string notes on bucket update become '?? null', the mutating bucket SQL
is user-scoped, and the MCP region/country-places tools uppercase their codes
to match REST.

Quirks deliberately preserved: the detached GET-path place_regions backfill
writes, getStats' two divergent return shapes, the import-time cache-cleanup
interval, getCountryFromAddress's unvalidated bare 2-letter codes, and the
whitespace-only-name silent no-op on bucket update.

Tests move with their case IDs: atlasService.test.ts becomes
unit/nest/atlas.service.test.ts (pure-geo imports from atlas-geo, DB calls
through the constructed service, plus bridge-delegation cases), the atlas e2e
converts to the DI-native pattern (real SQL against the full temp-db schema),
and the plugin-host path mock becomes a constructor stub.
2026-08-01 15:50:36 +02:00
jubnl c78134831f fix(server): tidy collections quirks after the DI fold 2026-08-01 14:03:05 +02:00
jubnl 79c20c07c8 docs: regenerate migration-graph after the collections fold 2026-08-01 13:54:36 +02:00
jubnl d11b7bb054 feat(server): validate collections reorder and delete-many bodies 2026-08-01 13:54:36 +02:00
jubnl cab49ea511 chore: move collections backend to proper DI 2026-08-01 13:54:19 +02:00
jubnl 4f8300e2ba fix(client): fall back to scheduled stop times in transit reservations 2026-08-01 12:49:22 +02:00
jubnl 2b03f9a024 fix(server): tidy transit itinerary quirks after the DI relocation 2026-08-01 12:49:22 +02:00
jubnl e80ae79b77 chore: move transitItineraryService into nest/transit as pure helpers 2026-08-01 12:45:35 +02:00
jubnl 57cc676e8a test(uploads): pin the uploads subdir list across Dockerfile and boot
The set of uploads subdirectories is hand-mirrored between the image build
and the boot-time mkdir in server/src/index.ts, and drift is invisible
until a user whose bind-mounted uploads dir is not writable by node hits an
EACCES on the first upload to the missing dir (#1762).

Parse both lists and assert they match, with a guard case so the regexes
cannot silently match nothing and pass vacuously.
2026-07-31 15:41:02 +02:00
jubnl 1fb6d19f6f fix(uploads): pre-create journey and places upload dirs
Both were created lazily on the first upload, which needs write permission
on the uploads mount point itself at request time. On hosts whose
bind-mounted uploads dir is not writable by the runtime user, every feature
writing into an already-existing subdir keeps working while journey photo
and place image uploads fail with EACCES (#1762). Creating them in the
image and at boot turns that into one loud startup failure instead of a
stray 500 on upload.

Also drop the recursive chown over all of /app from the final layer: it
copied up every inode it touched and duplicated node_modules, dist and the
client bundle into a 518 MB layer. The chown now rides the layer that
installs node_modules, later copies carry --chown=node:node, and the last
layer chowns only the paths it creates. Everything under /app stays
node-owned; image size 2.36 GB -> 1.68 GB.
2026-07-31 15:40:24 +02:00
jubnl f02f255016 fix(places): cap the Naver list-import response like the Google one
The place fold capped the Google list-import body at 8 MB but left the Naver
importer reading its pages unbounded — same attacker-influenced URL (the folder
id), and worse in practice because that path is a pager that buffers a fresh
body on every iteration.

fetchPage now checks the declared content-length before reading and the actual
length after, returning the existing 'Failed to fetch list from Naver Maps' 502
on either. Reading the body as text and parsing it explicitly (rather than
apiRes.json()) is what makes the post-read check possible; the malformed-payload
branch keeps its 'Invalid list data received from Naver Maps' 400 because the
JSON.parse stays inside the same try.

The post-read check is the one that matters for a hostile server: a chunked
response carries no content-length, so the declared check alone is evadable.
Covered for both providers now — PLACE-SVC-073b (Google, chunked) and
PLACE-SVC-074..077 (Naver: declared, chunked, malformed, happy path).

The five Naver fetch mocks in the integration suite move from json() to text().
2026-07-30 17:53:48 +02:00
jubnl 4f0f80ad69 chore: move place backend to proper DI
Fold the 1029-line services/placeService.ts into the DI-native PlacesService:
the CRUD + ratings SQL, the GPX/KML/KMZ importers and the Google/Naver list
importers become methods over the injected DatabaseService, byte-identical
statements, COALESCE semantics and the If-Match conflict protocol (#1135).
The pure half — frozen XML parsers, the KMZ unpacker, the dedup predicates,
the Google hex-id parsers, reclaimPhotoCache — moves to places.helpers.ts
(maps.helpers precedent).

The 10-tool mcp/tools/places.ts registrar plus the trek://trips/{tripId}/places
resource move onto the decorator registry as places.mcp.ts. search_place travels
with them because its gate is places:read, not the read-only geo group, and now
injects MapsService. TripsService, DaysMcp, BookingImportService and the plugin
RPC host (PluginHostDepsFactory's 21st constructor dep) all inject PlacesService,
so the domain needs no bridge at all. The two assignments.bridge imports that
places.mcp.ts keeps are a deliberate cycle break: AssignmentsModule imports
DaysModule and DaysModule now imports PlacesModule for days.mcp.ts's place
creation, so injecting AssignmentsService there would close
DaysModule -> PlacesModule -> AssignmentsModule -> DaysModule. Same seam
reservations.mcp.ts uses, same trade trips.bridge.ts documents.

Also folds services/placeEnrichment.ts in — its DB/websocket/Maps half becomes
PlacesService methods over the injected DatabaseService/RealtimeService/
MapsService, its pure match selector joins places.helpers.ts — which retires
nest/maps/maps.bridge.ts with its last consumer.

Adopts places.dto.ts for the seven grandfathered body contracts and clears every
PlacesController entry from body-contract-allow-list.ts. Two consequences worth
knowing: the bespoke 400s 'Place name is required', 'ids must be an array of
numbers' and 'URL is required' are now the ZodValidationPipe envelope, and
because a pipe runs before the handler a malformed body 400s ahead of the
trip-access 404 it used to follow — the same trade the todo and trips migrations
took. placeBulkUpdateRequestSchema.ids drops .min(1) so the endpoint's
empty-list short-circuit stays reachable.

Fixes four defects the relocation had faithfully carried, each with a
failing-first regression test:

- create dropped lat/lng of exactly 0, so a place on the equator or the prime
  meridian lost its coordinates (`x || null` cannot tell absent from zero).
- duration_minutes: 0 was unsettable — replaced by the 60-minute default on
  create, and read as "absent" by COALESCE on update.
- the journey delete hook fired on unscoped ids: DELETE /:id, POST /bulk-delete
  and the delete_place MCP tool could detach another trip's journey entries for
  an id they then refused. All three scope first via the new
  PlacesService.scopedIds, mirroring the guard PluginHostDepsFactory already had.
  bulk_delete_places also moves its hook ahead of the DELETE —
  journey_entries.source_place_id is ON DELETE SET NULL, so running it afterwards
  left the entries as orphans.
- the place search treated % and _ as LIKE wildcards; a bare % returned the whole
  trip. The three clauses now carry ESCAPE with the term escaped.

Plus hardening on the Google list fetch: the response is capped at 8 MB
(declared content-length and post-read length, the transit.service precedent)
and its JSON.parse is guarded, so a malformed provider payload produces the
existing 'Invalid list data received from Google Maps' 400 instead of throwing.

Quirks deliberately preserved: the non-COALESCE route_color (#776),
currency/transport_mode staying unclearable, importGpx returning null for a file
that parses but yields no usable geometry, update skipping the If-Match check
when the stored updated_at is null, and every string-valued `x || null`.

Tests move with their case IDs: placeService.test.ts and the wrapper suite merge
into unit/nest/places.service.test.ts, kmzUnpack into places.helpers.test.ts,
placeEnrichment into places.enrichment.test.ts. The places e2e converts to the
DI-native pattern (real SQL against temp-db DDL), tools-places.test.ts is kept
and extended so it exercises the decorator path through the test registry, and
the plugin-host and booking-import path mocks become constructor stubs.
2026-07-30 17:07:12 +02:00
xthephreakx 57017b8aed fix(navbar): prevent centred tab pill overlapping actions on tablet (#1747)
The desktop navbar's centred tab pill is absolutely positioned, so on the
md-range (tablet, ~768-1024px) its right edge slid underneath the right-hand
action cluster (notification bell + user menu) once the tab text labels made it
wide enough.

Hide the tab text labels below the lg breakpoint (icon-only on tablet, icon +
label on desktop ≥1024px), which keeps the pill narrow enough to clear the
actions. The icons gain title/aria-label so they stay accessible without text.

Co-authored-by: phreaky <phreakydev@gmail.com>
2026-07-29 22:09:40 +02:00
jubnl cd67074e0b Merge remote-tracking branch 'origin/dev' into dev 2026-07-29 21:40:35 +02:00
jubnl 4e0ac74fcb chore(deps): bump @modelcontextprotocol/sdk to ^1.30.0
Security fixes in the MCP SDK.
2026-07-29 21:40:05 +02:00
jubnl c7760cb823 docs: regenerate the legacy-services dependency graph after the transit fold 2026-07-29 21:40:05 +02:00
jubnl f4bf7cf3dc chore: move transit backend to proper DI
Includes the quirk-repair pass (upstream timeout + size cap, LRU cache, NaN-duration guard) that maps landed as a sibling fix commit.
2026-07-29 21:39:51 +02:00
Maurice 129f952db4 fix(pdf): stop a flowing day stranding its header at the foot of a page
Keeping a stay whole moved its cards to the next sheet, and the header bar
stayed behind on the old one — empty, above a stretch of white, with the
same day printed again overhead. The repeat itself is the continuation
marker from #1471; what was wrong is that there was nothing left to
continue from.

break-after on the thead and break-before on the tbody are both ignored by
Chromium here, measured rather than assumed. Holding the whole day together
is what works: a day that fits moves as one, and a day too long for a page
still breaks and still repeats its header. Scoped to the flowing layout,
since a day that starts its own page can never strand one.
2026-07-29 21:32:39 +02:00
Maurice eb817dea65 fix(pdf): keep a stay whole across a page edge
An accommodation could be cut in half by a page break — the check-in time
at the foot of one sheet, the hotel name and address at the head of the
next, under a repeated day header that read like a duplicate day. Place and
note cards have carried break-inside: avoid all along; this one never did.

It could always happen to a day that overflowed its page. Letting the days
flow means content meets a page edge far more often, which is how it turned
up. Printing the same trip through Chromium: two torn stays before, none
after.
2026-07-29 21:32:39 +02:00
Maurice 5dbb2db3ee fix(planner): keep the PDF tooltip inside the window
The export button is the one toolbar button with a hand-rolled tooltip
instead of the shared one. It was anchored with `right: 0` and never
wrapped, so its label grew leftwards out of the day plan — the leftmost
pane — and off the window.

The shared tooltip is portalled and clamped to the viewport, which is what
every neighbouring button already uses. The hover state it needed was
threaded down from DayPlanSidebar and has no other reader, so it goes too.
2026-07-29 21:32:39 +02:00
Maurice be5b48532d fix(pdf): dress the layout switch like the rest of the app
The preview shipped a bare checkbox, which is not what a toggle looks like
anywhere else in TREK. The overlay is imperative DOM, so the switch from
Settings/ToggleSwitch cannot be rendered into it — as static markup it would
carry no behaviour — but its geometry and its tokens can be, and the control
now reads as the same one.
2026-07-29 21:32:39 +02:00
Maurice 03be438e0b feat(pdf): make the page break between days optional
Every day started a new page, so a plan of short days printed one sheet per
handful of lines and looked nothing like the view it was exported from.

The preview now carries the choice, next to Save as PDF, and remembers it.
The two layouts differ by a single class on <body>, so ticking the box
re-lays the open preview out immediately instead of rebuilding the document
and re-fetching every photo.

Breaking per day stays the default — this only adds the other option.
2026-07-29 21:32:39 +02:00
Maurice dc4ccb2304 chore(i18n): add the PDF page break label 2026-07-29 21:32:39 +02:00
jubnl a7cb4eb880 chore: point MCP.md to the wiki 2026-07-29 21:24:57 +02:00
jubnl 09f7cc4811 fix(maps): repair the quirks preserved by the DI fold 2026-07-29 20:27:30 +02:00
jubnl 74e1e444c5 docs: regenerate the legacy-services dependency graph after the maps fold 2026-07-29 20:21:10 +02:00
jubnl 4706112516 feat(server): adopt zod DTOs for the maps body contracts 2026-07-29 20:21:09 +02:00
jubnl 2e40c19249 chore: move maps backend to proper DI 2026-07-29 20:20:57 +02:00
Maurice 397f504d01 fix(planner): reach the day reorder popup with a finger too
The popup is a modal, so it portals out of the sidebar and misses the
opt-in its rows sit inside everywhere else. Its rows already carried a bare
draggable attribute, which on a tablet meant a long press selected the row
label and nothing moved.
2026-07-29 19:15:57 +02:00
Maurice edea67ff7e fix(planner): let tablets drag places into the day plan (#1616)
Dragging a place from the right pane to the left one did nothing on an
iPad or a Samsung tablet — the row was selected as text instead. Phones
never reach this layout, they get the mobile shell below md, so the coarse
pointer check in dragDisabled was only ever switching tablets off.

It was also doing that for a reason that had gone away. What swallowed the
scroll gesture in #1432 was the drag-drop-touch polyfill, and that has been
confined to hybrid laptops since. Width is the gate that still means
something: below lg the plan and the places are separate tabs, so there is
nothing to drag between.

So touch is no longer a reason to disable the drag, and the planner arms
the long-press bridge instead where the pointer is coarse. Rows get
-webkit-touch-callout and user-select off there too, otherwise the press
that starts the drag brings up the selection first. The isTouch prop had no
other use and is gone, along with a comment claiming arrow reorder buttons
take over on touch — there are none.
2026-07-29 19:15:57 +02:00
Maurice e7ccbcdf86 feat(planner): add a long-press touch drag bridge
A finger never starts an HTML5 drag on Android, and the answer used to be
drag-drop-touch: a document-wide polyfill that turned every touch over a
draggable element into a drag. That cost the places list its scrolling
(#1432) and the map its one-finger pan (#1440), so it now only loads on
hybrid laptops.

This is the narrow version. It only watches touches that begin on a
draggable row inside a container that opted in, and it waits out a long
press before claiming the gesture, so a swipe is still a swipe. Once armed
it replays the real drag sequence on the element under the finger, which
leaves every existing drop handler working as it is.

Nothing is wired up to it yet.
2026-07-29 19:15:57 +02:00
Maurice 98ad2450a2 feat(db): make the SQLite journal mode configurable (#1675)
WAL was hardcoded, and on network-backed storage — Azure App Service Linux,
SMB/NFS volumes on NAS and PaaS — SQLite documents it as unsafe, because the
coordination runs through the -shm file and mmap. Operators had no way out.

TREK_DB_JOURNAL_MODE and TREK_DB_SYNCHRONOUS now set both pragmas, defaulting
to today's behaviour. The mode lives in the file header, so all three
processes that open travel.db read the same configuration — the server,
reset-admin.js and migrate-encryption.ts, which used to hard-set WAL and would
have silently reverted a deliberate DELETE setup on the first key rotation.

An invalid value falls back and warns rather than refusing to start, and the
mode that actually took effect is read back and logged, so an operator can see
whether the setting landed.
2026-07-29 17:02:56 +02:00
Maurice e62dceb4bb fix(backup): snapshot the database instead of archiving it live (#1675)
The scheduled backup archived travel.db directly. The archiver reads its
entries during finalize(), so a WAL checkpoint writing pages back mid-stream
tore the copy — and the -wal that would make it recoverable isn't in the zip.
The manual path was moved to VACUUM INTO for exactly this reason; the
scheduler never followed. It calls the same createBackup() now, which also
gets it the encryption key and plugin data it was missing, so a scheduled
backup is finally restorable onto another install.

The pre-rotation copy in migrate-encryption.ts had the same problem: a plain
file copy of a live WAL database, and it is the operator's only rollback if a
key rotation goes wrong.

Auto-backups now exclude the re-derivable photo caches, matching manual ones.
2026-07-29 17:02:56 +02:00
Maurice 14bc41f2cf chore(i18n): drop the unused map template placeholder key
Left behind by the tile URL change (#1733): all four call sites use the
.select variant, the plain key had no consumer.
2026-07-29 15:14:21 +02:00
Maurice f35c3f31e2 fix(transit): prefer displayName for the line identifier (#1715)
Transitous exposes routeShortName, which many operators — German long
distance among them — do not fill. displayName carries the identifier
passengers actually see, with routeShortName as the fallback.
2026-07-29 15:14:21 +02:00
Maurice 4f99834965 test(admin): stop the plugin sheet test racing its own Escape
MSheet binds the Escape listener in an effect, so a key fired the moment the
sheet content appears can land before the listener exists. Under a loaded
full-suite run that made the test fail on its own timing. It retries until
the sheet is gone rather than pressing once.
2026-07-29 15:14:21 +02:00
Maurice 8b7e2ddaa5 fix(places): evaluate open/closed in the place's timezone (#1680)
The ring was checked against the viewer's clock, so a trip in Europe planned
from Singapore showed the wrong state. It evaluates in the timezone of the
place now, from Google's structured opening periods rather than the localised
description text — parsing display strings breaks as soon as the account
language is not English. Midnight-spanning hours, closed days and 24/7 are
covered.
2026-07-29 15:14:21 +02:00
Maurice 724b52aa92 fix(plugins): broadcast itinerary.unassign to open sessions (#1705)
assign broadcast, unassign did not, so the place stayed on the Plan tab until
a manual refresh. It emits the same event as the REST route now. createPlace,
updatePlace and deletePlace in the same file had the identical gap.
2026-07-29 15:14:21 +02:00
Maurice aaef3e3e76 fix(reservations): honour the 24h time setting (#1725)
The 12h/24h rules lived in three copies and two of them ignored the setting.
They are one function now. splitReservationDateTime also truncated the
meridiem instead of converting it, so 3:00 PM became 3:00 — three in the
morning. Values already stored that way normalise when a form renders the
field, without changing the payload or the stored format.
2026-07-29 15:14:21 +02:00
Maurice 9ab9665e99 fix(llm-parse): decode .eml as MIME before extracting text (#1724)
Attachments were treated as ready-made HTML, but a real mail is multipart
with base64 or quoted-printable transfer encoding. What reached the model was
the plaintext headers followed by a wall of base64, truncated before any
content — which is why a sender name from the Subject line still showed up
while the booking never did.

The html part is preferred, plain text is the fallback, transfer encoding and
charset are decoded and entities resolved before the text goes out. No new
dependency. If no text part is found the previous raw path still runs.
2026-07-29 15:14:21 +02:00
Maurice 50600b74cd fix(maps): stop the photo endpoint answering 404 for photo-less places (#1727)
Opening one trip fires a 404 per place without a photo, 30 to 60 of them from
one IP in seconds. CrowdSec and fail2ban read that as scanning; the reporter
had three users banned within five minutes. A place having no photo is not an
error and no longer answers as one.

OSM places were also sent to Google as node:/way:/relation: ids, which can
only return 400 INVALID_ARGUMENT — a billed round-trip and about 1.3s per
place. getPlacePhoto already guarded coords: this way; the OSM prefixes now
get the same treatment, as does getPlaceDetailsExpanded, which had no guard.

The negative cache separates a place having no photo from a failed provider
call instead of expiring both after five minutes and replaying the storm.
2026-07-29 15:14:21 +02:00
Maurice 9ecca404c5 fix(map): drop the retired OpenStreetMap tile subdomains (#1733)
The a/b/c shards stopped being necessary in 2022 and d has since lost its
DNS record. The map never asked for d — Leaflet defaults to abc — but the
offline prefetcher rotated over a to d, which is where the failing requests
came from, and it cached most tiles under a host the map never requests.

Presets and the prefetcher use the apex host now. A URL already saved in
user_settings is rewritten where it is read and written back, so existing
instances heal without a migration touching what someone typed themselves.

The CSP only allowed https://*.tile.openstreetmap.org, and a wildcard host
never matches the apex domain, so the prefetch fetch would have been blocked.
The service worker rule had the same gap and would have stopped filling the
tile cache.
2026-07-29 15:14:21 +02:00
Maurice f013c4964c chore(client): set every coverage threshold to 85
Treats the gate as a floor rather than a target — the suite runs well above it,
and a PR that legitimately trades a few points should not have to move the
numbers to land.
2026-07-29 12:56:17 +02:00
Maurice 72a82b3c44 fix(client): work through the findings from the coverage pass
214 items came out of writing the tests. Real defects are corrected, dead code
is removed rather than covered, and the tests that pinned the old behaviour
move with them.

Also drops two agent coverage scratch files that slipped into the coverage PR
and adds the ignore rule that keeps them out.
2026-07-29 12:35:00 +02:00
Maurice a4bf815d24 test(import): read the upload body instead of parsing it as FormData
These are the only two test files that call request.formData() inside an MSW
handler, and they hold exactly the three tests that failed on CI while passing
locally. The body axios' XHR adapter produces under jsdom is one Node 24's
multipart parser rejects, so the handler threw before reading a field and the
component reported a failed upload. Reading the raw payload asserts the same
thing without depending on the parser.
2026-07-29 11:55:31 +02:00
Maurice 1d9fe9a3df test(import): stop the upload fixtures lying about their size
The helpers built a one-byte File and then redefined size to 100, so the
multipart body and the length it announced disagreed. Node 24 rejects that
while 22 lets it through, which is why only CI saw it: every test that
uploaded such a file and expected success failed, the one expecting an error
passed for the wrong reason, and the KML case with a truthful File was fine.
2026-07-29 11:55:31 +02:00
Maurice be0164eb91 ci: run the client gates alongside the suite instead of in front of it
The client suite is ~11 minutes now. With typecheck and lint sitting in front
of it in the same job, a single lint finding threw away the whole test signal
for that run — twice today. Both jobs pay the install and the shared build,
which is cheap next to running the two in series.
2026-07-29 11:55:31 +02:00
Maurice 16922766e5 test: give async assertions a realistic budget on CI
waitFor and findBy* default to 1s. That holds on a dev machine but not on a
4-core runner executing 527 test files in parallel forks, where three import
dialog tests timed out waiting for their upload to settle.
2026-07-29 11:55:31 +02:00
Maurice 99816bff4c test(costs): escape the BOM in the CSV export assertion
A literal U+FEFF inside a regex trips no-irregular-whitespace, which is an
error rather than a warning, so the client lint job failed. The escape is
equivalent.
2026-07-29 11:55:31 +02:00
Maurice 02ad6c3d16 test: close the remaining gaps and raise the thresholds
Pushes lines from 95.2% to 98.1% and branches from 85.2% to 89.9%, mostly
in the release-notes modal, the reservation overlay, the journey entry sheet
and the trip dialogs.
2026-07-29 11:55:31 +02:00
Maurice b80bd3992b test: pick up the rest 2026-07-29 11:55:31 +02:00
Maurice 1e66bcc701 test(pages): cover the page hooks, stores and the api client 2026-07-29 11:55:31 +02:00
Maurice bbb0f93af2 test(components): cover map, collections, settings, journey and admin 2026-07-29 11:55:31 +02:00
Maurice a3b263017f test(planner): cover the day plan sidebar and the planner dialogs 2026-07-29 11:55:31 +02:00
Maurice 12558fe9c4 test(mobile): cover admin, settings and the remaining screens 2026-07-29 11:55:31 +02:00
Maurice c071901e55 test(4-0): cover the KEINE ANGST show 2026-07-29 11:55:31 +02:00
Maurice c884ddf527 test(mobile): cover the trip screens 2026-07-29 11:55:31 +02:00
jubnl 287dceb532 chore: wire TripsService into the plugin RPC host and retire the legacy tripService
Swap the factory's six tripService imports for the injected TripsService; delete services/tripService.ts and the collab/vacay bridges with it; prune the days/budget bridges to their surviving exports; update the migration docs and regenerate migration-graph.md.
2026-07-29 01:53:54 +02:00
jubnl 530c7cb005 chore: move trip backend to proper DI
Fold the legacy tripService SQL into TripsService; port the trips MCP registrar/resources/trip-summary prompt to trips.mcp.ts and the share-link tools to share.mcp.ts (predicate access for the trips:delete/trips:share/canReadTrips gates, deprecation notice via the attach ctx); adopt trips.dto.ts and clear the seven allow-list entries; delete todo/share bridges with their last consumers. Includes the trailing quirk fixes: transactional deleteTrip/deleteGuest, owner display_name in listMembers.
2026-07-29 01:53:40 +02:00
jubnl a3c2cfa4b2 fix(shared): tighten WS payload schemas after the MCP drift fixes
assignment:moved requires newDayId again; assignment:reordered and
day:deleted drop their drift-union variants now that every emitter
sends the canonical shape.
2026-07-29 00:34:58 +02:00
jubnl 23486f841f fix(server): align MCP assignment/day WS payloads with the REST shapes
move_assignment omitted newDayId (moved assignments vanished from
collaborator views), reorder_day_assignments sent assignmentIds where
the client reads orderedIds (reorders emptied the day remotely), and
delete_day sent { id } where the client reads dayId (days never
disappeared). Registry drift findings from the WS contract work.
2026-07-29 00:34:53 +02:00
jubnl a8a5f7e611 chore: update sonar props 2026-07-29 00:24:26 +02:00
jubnl a2011dc991 feat(client): handle WS events against the shared registry
remoteEventHandler's two switches become enumerable lookups keyed by
TrekWsTripEventName (bodies unchanged); WebSocketEvent.type derives
from the registry. wsEventPolicy.ts makes the client's non-reactions
explicit (HANDLED_OUTSIDE_TRIP_STORE + IGNORED_WS_EVENTS) and the
registry-parity test turns an unclassified server event into a red
test instead of silence. Zero behavior change.
2026-07-29 00:17:47 +02:00
jubnl f66f8d8bd9 feat(server): type RealtimeService against the event registry
broadcast/broadcastToUser gain registry-typed overloads (event name
must be a TREK_WS_EVENTS key, payload must match its schema) with the
plugin: namespace as the deliberate escape hatch; the 13 service
wrappers forward the generic signature. Compile-time only — the
runtime pass-through and all call sites are unchanged.
2026-07-29 00:16:08 +02:00
jubnl c0baaeb898 feat(shared): WS event contract registry
TREK_WS_EVENTS: 65 trip-scoped + 29 user-scoped events with Zod
payload schemas transcribed from the emitting call sites; derived
name unions and payload types; plugin: namespace reserved as the
escape hatch. 8 payload-drift shapes modeled as unions and flagged.
2026-07-29 00:15:56 +02:00
jubnl a2861d40f3 feat(server): injectable RealtimeService over the ws singleton
Nest services inject RealtimeService (call-time delegation to the
websocket module, DatabaseService pattern) instead of importing
broadcast/broadcastToUser globals. Zero behavior change; test edits
are constructor-arg additions plus RealtimeModule in e2e harness
imports only.
2026-07-29 00:07:51 +02:00
jubnl 80f3958569 fix(server): silence expected-error noise in test stderr 2026-07-28 22:31:46 +02:00
jubnl c7dc38a5b9 fix(server): close the notification-send teardown race in tests 2026-07-28 22:31:40 +02:00
jubnl 65fb00d63b feat(server): type MCP access groups against SCOPES + boot gate 2026-07-28 20:56:43 +02:00
jubnl 59ea420f1d feat(nest-mcp): host-typed access groups + entry validation hook 2026-07-28 20:56:29 +02:00
jubnl ee2ea1d065 refactor(server): extract addon enablement into the addons domain 2026-07-28 20:22:01 +02:00
jubnl cafff003a3 refactor(server): move instance-URL resolution to app-config 2026-07-28 20:21:09 +02:00
Maurice 292f1b181c Document track colours in the wiki
Map-Features had no GPX section at all, so the tracks paragraph is new
rather than an addition.
2026-07-28 07:25:36 +02:00
Maurice 2b8b55aa8f Translate the track colour strings 2026-07-28 07:25:36 +02:00
Maurice 502f604ca2 Reach the track colour from the mobile sheet
MPlaceSheet had no relation to route_geometry at all, so the section is
new. It saves through the single-field patch the sheet already uses for
the image, which keeps it clear of the edit form and its prefill.

The browse list gets the same colour stroke as the desktop sidebar. It
matters more here: the mobile map is full-bleed with no sidebar next to
it, so without the stroke a coloured line has nothing to tie it to.
2026-07-28 07:25:36 +02:00
Maurice 3c575a06f4 Add the track colour picker to the place inspector
New shared component rather than a ninth copy of the swatch grid that is
already inline in eight places. Selection is drawn with var(--accent) so
it follows whatever accent the user runs.

The custom-colour cell listens for the native change event instead of
React's onChange: for a colour input React maps onChange to input, which
fires on every drag inside the OS picker — dozens of writes, each one a
409 candidate against the base-version header. The auto cell previews the
colour you would get back, not the one currently set, which is a
different value as soon as anything is picked.

The row sits next to the track stats block, not inside it: that block
returns null on unparsable geometry and the colour control has no
business disappearing with it. The elevation chart and the distance pin
follow the track colour too, otherwise the panel and the map read as two
unrelated objects.

In the places list the track icon becomes a stroke in the colour the line
is drawn in. The map has no legend of its own, so that stroke is the only
thing tying a line back to a row. Undo after deleting a track now
restores its geometry and colour instead of bringing back a bare point.
2026-07-28 07:25:36 +02:00
Maurice fcabb4c645 Draw tracks in their own colour on both map engines
The MapLibre layer was already data-driven, so it only needed the feature
property filled. Leaflet needed more work: the GPX polyline passed its
style as bare color/weight props, and react-leaflet only calls setStyle
when the pathOptions reference changes — a recoloured track would have
kept its mount-time colour until the next reload. Every other vector
layer in here already uses pathOptions.

A track with a colour gets a white casing so it survives satellite
imagery and dark basemaps. The casings live in their own pane below the
lines: Leaflet stacks paths in insertion order, so drawn inline they end
up over the line of a track that was added earlier. They are always
mounted and hidden by opacity, otherwise giving a track a colour remounts
the path into the wrong position.

Tracks are clickable now — 3.5px is not a target, so the hit area is a
transparent 14px line on top. bubblingMouseEvents is off on it: paths
bubble to the map by default (markers do not), and the map's own click
handler would clear the selection the track just made. The GL side needs
the mirror image of that, a guard so a click on a cluster bubble over a
track still belongs to the cluster.
2026-07-28 07:25:36 +02:00
Maurice 1b5317a817 Give GPX tracks their own colour (#776)
Imported tracks all render in the same blue: the GPX/KML importers never
assign a category, so category_color is null for every one of them and
several walks in the same area are impossible to tell apart.

Adds a nullable route_color on places, kept out of the COALESCE group in
updatePlace so an explicit null can reset a track back to inheriting its
category colour. The hex is validated in the Nest controller rather than
in the shared contract: the update body is an open record on purpose, and
tightening it would mean removing PlacesController.update from the
ratchet-only body-contract allow-list.

The importers now hand each new track a colour of its own, picked from
the palette entries the trip is not already using. That happens in the
Nest service over the injected DatabaseService — the colour assignment is
new logic, and new logic does not belong in the legacy place service (see
server/src/nest/README.md). The field itself is only threaded through the
existing update/create statements, which the proportionality rule covers.

Also fixes duplicateTrip, whose explicit column list has been silently
dropping route_geometry all along, so a duplicated trip lost its tracks.
2026-07-28 07:25:36 +02:00
jubnl 351b5fb4ec fix(server): fix the quirks preserved by the budget DI migration 2026-07-28 00:04:50 +02:00
jubnl 40f3103c04 docs: tick budgetService in migrate.md and refresh the migration graph 2026-07-27 23:48:23 +02:00
jubnl cf1b9e4c06 feat(server): validate budget request bodies with shared Zod DTOs 2026-07-27 23:48:23 +02:00
jubnl 7f34dadf70 chore: move budget backend to proper DI 2026-07-27 23:48:05 +02:00
jubnl b0041f34ba fix(server): fix the quirks preserved by the exchange-rates DI migration
- the Frankfurter fetch now carries an AbortSignal timeout (10s) and a 1 MB
  response-size cap, and the body is boundary-validated through a type guard
  instead of an as-cast
- fetch failures are logged instead of silently swallowed (callers still get
  the graceful null)
- the inflight-coalescing cleanup moved into .finally so a rejected fetch
  can no longer leak the key
- the dead convertWithRates export (zero call sites) is dropped from the
  service and the bridge

Kept on purpose: the beyond-TTL stale-cache fallback, the >1-keys
empty-response heuristic and the || falsy-coercion defaults.
2026-07-27 23:03:59 +02:00
jubnl 166fe798a7 docs: tick the exchangeRateService fold in migrate.md and refresh the migration graph 2026-07-27 22:58:14 +02:00
jubnl a3e6eb52a4 chore: fold exchangeRateService into the nest budget domain 2026-07-27 22:58:09 +02:00
jubnl 132eb2035f fix(server): fix the quirks preserved by the permissions and auditLog DI migrations
Permissions: stored levels are validated at load (corrupt/out-of-range rows
fall back to the default consistently across all readers), the load-error
swallow is narrowed to missing-table (anything else logs), and an
all-skipped save no longer opens a transaction or flushes the cache.
The module-scoped cache stays by design (shared DI+bridge invalidation),
and the checkPermission 'admin' case stays — it is reachable (correct deny
for non-admins on admin-level actions), not dead code.

Audit: data/logs creation is lazy (no import-time disk side effect; the
LOG_LEVEL freeze remains — tests/setup.ts timing contract), the log helpers
gate on a real severity threshold (error < warn < info < debug; prod
default 'info' unchanged), file-IO failures leave a console.error trace
instead of vanishing in bare catches, and resolveUserEmail treats only
null/undefined as anonymous so a real id 0 resolves via the DB.
2026-07-27 22:34:41 +02:00
jubnl 2944cd32a1 docs: tick permissions and auditLog in migrate.md and refresh the migration graph 2026-07-27 22:09:08 +02:00
jubnl 6ff936c613 chore: move auditLog backend to proper DI 2026-07-27 22:09:08 +02:00
jubnl 1d56de3897 chore: move permissions backend to proper DI 2026-07-27 22:08:37 +02:00
jubnl 55cb2148d4 fix(server): fix the quirks preserved by the day DI migration 2026-07-27 20:58:03 +02:00
jubnl c276e3a79a docs: tick dayService in migrate.md and refresh the migration graph 2026-07-27 20:57:18 +02:00
jubnl 2d1eec76c3 chore: move day backend to proper DI 2026-07-27 20:57:01 +02:00
jubnl e62628bf93 feat(server): adopt zod DTOs for the day and accommodation body contracts 2026-07-27 20:56:45 +02:00
jubnl d952eaf4d4 fix(server): fix the quirks preserved by the reservations DI migration
Two verified defects carried through the parity fold:

- The create-path accommodation metadata sync gated on the raw
  accommodation_id instead of the resolved one, so a hotel whose
  accommodation was just auto-created never received its metadata
  check-in/out times or confirmation. Now keyed off resolvedAccommodationId
  (RESV-SVC-006 re-pins the fixed behavior).
- The multi-statement writes ran outside transactions: create (accommodation
  insert + reservation insert + endpoint save + metadata sync), update
  (accommodation upsert + update + endpoint replace + sync), remove (the
  3-delete cascade), setReservationTravelers (delete + inserts) and
  resyncReservationDays now run in db.transaction() — a mid-write failure no
  longer leaves partial state (RESV-FIX-001/002 pin the rollbacks).

Left as-is on purpose: the truthy updatePositions dayId check, the
empty-string COALESCE keeps on title/status/type, the TEXT accommodation_id
normalization, the optional day_plan_position wire tolerance and the
swallowed notifyBookingChange catches — all contract or intentional
behavior, not defects.
2026-07-27 19:52:00 +02:00
jubnl c7dde2af2e docs: tick reservationService in migrate.md and refresh the migration graph
- nest/README.md: reservations joins the DI-native list; recipe history gains
  the residue-fold paragraph; next up is dayService per the dependency-honest
  order.
- migrate.md: wave-4 entry ticked, with the migration-graph correction borne
  out — reservationService imported neither budgetService nor dayService.
- migration-graph.md: regenerated for the post-fold import graph (row/node
  removed, frontier and order updated, dayService is the next pick).
- plugins/DI-MIGRATION.md: the factory's last plain-function reservation
  import is drained.
2026-07-27 19:47:09 +02:00
jubnl 203d67c45f chore: move reservations backend to proper DI
Folds the 626-line services/reservationService.ts into the DI-native
ReservationsService — byte-identical SQL, statuses, bodies, error strings
and broadcasts; the legacy file is deleted.

- reservations.mcp.ts: the 5 legacy tools (create/update/delete_reservation,
  reorder_reservations, link_hotel_accommodation) + the trip-reservations
  resource move to the decorator registry (identical names, schemas,
  annotations, error strings, broadcasts); the canWrite/canRead registration
  gates become declarative access markers. Legacy registrar and resource
  entries removed.
- reservations.bridge.ts: 9 legacy-named exports + 3 type re-exports for the
  outside-container consumers (tripService, airtrail import/sync, the
  still-legacy transit + transports registrars, transitItineraryService's
  type-only import).
- notifyBookingChange folds to the legacy (tripId, actorId) signature; the
  controller passes user.id and the plugin-host factory drops its last
  plain-function reservation import for a private method over the injected
  service (constructor unchanged).
- TripsService and BookingImportService inject ReservationsService instead of
  importing legacy functions.
- Tests: reservations.service.test.ts rewritten as a real-SQL suite
  (RESV-SVC-001..030, RES-TRAV-001..005 moved verbatim,
  RESV-BRIDGE-001..009 pinning the bridge); both e2e suites converted to the
  DI-native pattern; path mocks repointed to the bridge or constructor stubs;
  tools-reservations kept and extended with the moved resource cases.

Quirks preserved on purpose (parity first — see the follow-up fix commit):
the raw-accommodation_id metadata-sync gate, the un-transactioned
multi-statement writes, the truthy updatePositions dayId check, the
empty-string COALESCE keeps, and the TEXT accommodation_id normalization.
2026-07-27 19:47:01 +02:00
jubnl c27fc99220 feat(server): adopt zod DTOs for the reservation body contracts
ReservationsController's 4 grandfathered @Body() params now validate through
the global ZodValidationPipe (createZodDto over the existing @trek/shared
schemas); their allow-list entries are removed (the boot gate rejects stale
entries). The bespoke 400 bodies (Title is required / positions must be an
array / user_ids must be an array) become the pipe's field envelope, and
non-numeric traveler ids now 400 instead of being silently dropped.

reservationPositionsRequestSchema is loosened to the real wire contract:
day_plan_position is optional (the legacy route never validated position
items and an absent value binds NULL) — pinned by RESV-006.

AccommodationsController stays grandfathered until the dayService migration.
2026-07-27 19:45:41 +02:00
jubnl fbc773386f fix(server): fix the quirks preserved by the vacay DI migration 2026-07-27 18:39:53 +02:00
jubnl e3547ec43b docs: tick vacayService in migrate.md and refresh the migration graph 2026-07-27 18:39:52 +02:00
jubnl 588457710c chore: move vacay backend to proper DI 2026-07-27 18:39:52 +02:00
jubnl 29e89a5774 feat(server): adopt zod DTOs for the vacay body contracts 2026-07-27 18:39:47 +02:00
jubnl a8d6663dc7 fix(client): send multiple_choice from the desktop poll form 2026-07-27 00:27:12 +02:00
jubnl 07b8f1234c fix(server): fix the quirks preserved by the collab DI migration 2026-07-27 00:27:12 +02:00
jubnl 20882f47b7 docs: tick collabService in migrate.md and refresh the migration graph 2026-07-27 00:27:12 +02:00
jubnl 49197c5370 feat(server): adopt zod DTOs for the collab body contracts 2026-07-27 00:27:11 +02:00
jubnl c08c595b7e chore: move collab backend to proper DI 2026-07-27 00:27:11 +02:00
jubnl 470e4dc92c feat(shared): make collab note optional fields nullable in the request contracts 2026-07-27 00:27:06 +02:00
jubnl f69731b72b docs(server): chronicle the fileService DI migration 2026-07-26 23:19:32 +02:00
jubnl f3e5be78c9 chore: move files backend to proper DI
Folds the legacy services/fileService.ts into the DI-native FilesService
(injected DatabaseService, byte-identical SQL). Load-time constants move to
files.constants.ts; files.bridge.ts survives with a single export for the
request-time getAllowedExtensions read the module-scope multer configs need
outside DI. TripsService and PluginHostDepsFactory inject FilesService.

Also rides the nestjs-zod ratchet (FileUploadDto/FileUpdateDto/FileLinkDto
over the @trek/shared contracts, FilesController entries dropped from the
body-contract allow-list) and fixes the verified legacy quirks: file-link
insert failures now surface instead of being swallowed, updateFile coerces
an empty-string description to NULL like createFile, and formatFile's
misleading optional trip_id annotation is gone.
2026-07-26 23:19:26 +02:00
jubnl dd3c03ba0f feat(shared): add fileUploadRequestSchema for the files upload body 2026-07-26 23:19:04 +02:00
jubnl 4a7d9cd0be refactor(plugins): route injectable-class SQL through the injected DatabaseService
PluginRuntimeService's operatorEgressHosts/pruneErrorLog and PluginsService's
egressHostCount were module-level helpers on the raw db proxy; they fold into
their classes as methods over the injected service. (PluginHostDepsFactory's
equivalent conversion rode the settings commit — same files.) No injectable
class under src/nest imports the db global anymore; the remaining importers
are the documented module-level seams (bridges, platform shell,
plugin-host-state, signature-status, the bare plugin user-setting readers).
2026-07-26 22:24:24 +02:00
jubnl e0530680cb feat(server): adopt zod DTOs for the settings body contracts
SettingUpsertDto/SettingsBulkDto wrap the existing @trek/shared schemas; both
SettingsController entries drop off the body-contract allow-list (boot
ratchet). Per the todo precedent the dead bespoke 400 guards go: missing/empty
key and a non-record settings body now answer with the pipe envelope
({ error: 'key: …' } / { error: 'settings: …' }) instead of 'Key is required' /
'Settings object is required', and arrays for settings are rejected. The local
masked-value literal now imports MASKED_SETTING_VALUE from @trek/shared.
The settings e2e registers ZodValidationPipe and pins the DTO 400s plus the
bulk masked-sentinel skip.
2026-07-26 22:24:14 +02:00
jubnl cece6509b9 chore: move settings backend to proper DI
Folds the legacy services/settingsService SQL into the Nest SettingsService
(byte-identical statements; BEGIN/COMMIT blocks become db.transaction()).
AdminService and ShareService inject it; llm-config.resolver becomes the
injectable LlmConfigResolver (SettingsService + DatabaseService), injected by
LlmParseService and PluginHostDepsFactory. No MCP surface, no bridge.

Whole-file commit, so this also carries the post-migration quirk fixes that
live in the same files: null serializes as '' (a stored "null" leaked out of
getDecryptedUserSetting as the literal string), bulk upserts skip the masked
sentinel (mirroring the single-upsert no-op) and return the written count.
Unit suite moved to tests/unit/nest with SET-SVC-001..026 preserved,
027..030 pin the fixes; settings/admin e2e converted to DI-native.
2026-07-26 22:23:57 +02:00
jubnl c9a9df5310 fix(server): harden share links — transactional writes, TTL renewal on update, scoped shared queries 2026-07-26 21:25:47 +02:00
jubnl d572b1ec8d feat(server): adopt zod DTOs for the share-link body contract 2026-07-26 21:25:34 +02:00
jubnl 72c3edda1a chore: move share backend to proper DI 2026-07-26 21:24:57 +02:00
jubnl efd0e7fa98 fix(server): import MCP session revocation from sessionManager, not the mcp barrel 2026-07-26 20:34:21 +02:00
jubnl 41b51849b7 feat(server): adopt zod DTOs for the assignment body contracts 2026-07-26 20:34:06 +02:00
jubnl 164f5152be chore: move assignments backend to proper DI and harden its writes 2026-07-26 20:33:31 +02:00
jubnl 9650ad4597 feat(server): adopt zod DTOs for the trip-invite body contracts
New tripInviteLinkCreateRequestSchema in @trek/shared (single source of
truth), a createZodDto wrapper on the controller's raw @Body(), and the
TripInviteLinkController.create entry dropped from the body-contract
allow-list (the boot ratchet enforces it).

expires_in_days accepts number | digits-only string | null | absent — the
string branch is digits-only so garbage like "7abc" is a 400 instead of
silently parseInt-ing to 7; the empty string (blank form input, meaning no
expiry) stays accepted. The e2e registers ZodValidationPipe and pins both
the bounded-expiry and 400 paths.
2026-07-26 18:21:18 +02:00
jubnl cb0cd3300d chore: move trip-invite backend to proper DI
Fold the legacy tripInviteService SQL into the Nest TripInviteService over
the injected DatabaseService (byte-identical statements; the JS-side expiry
check and non-positive-means-no-expiry coercion preserved) and delete the
legacy module. No bridge, no MCP port and no plugin-host change needed —
nothing outside the container consumed this domain.

Also wraps the rotation's probe + write + re-select in db.transaction()
per the repo rule that multi-statement writes are never bare.

Unit suite moved to tests/unit/nest preserving TRIP-INVITE-001..006 and
adding 007 (expiry pinning); the e2e converted to the DI-native pattern
(real SQL against temp trips/trip_invite_tokens tables).
2026-07-26 18:21:04 +02:00
jubnl 3762d3306c feat(server): adopt zod DTOs for the day-note body contracts 2026-07-26 17:44:25 +02:00
jubnl 4e49e9c21e chore: move day-notes backend to proper DI 2026-07-26 17:44:12 +02:00
jubnl 7242812b27 fix(trips): scope the offline bundle's packing items to the viewer (#858) 2026-07-26 16:57:02 +02:00
jubnl 934848e114 fix(packing): correct legacy quirks kept during the DI migration 2026-07-26 16:56:18 +02:00
jubnl 5da2abf4f9 feat(server): adopt zod DTOs for the packing body contracts 2026-07-26 16:55:53 +02:00
jubnl f1960dc364 chore: move packing backend to proper DI 2026-07-26 16:55:20 +02:00
jubnl 67b3005d36 docs(plugins): record Option A as implemented, correct bridge/next-up drift 2026-07-26 15:29:00 +02:00
jubnl 811230fa5d refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges 2026-07-26 15:28:55 +02:00
jubnl 167db04d69 refactor(plugins): split create-rpc-host into an injectable factory + host-state module 2026-07-26 15:28:39 +02:00
jubnl 6c583c2f27 feat(server): adopt nestjs-zod with a fail-closed body-contract boot gate
- One global ZodValidationPipe (APP_PIPE, createZodValidationPipe) validates
  any @Body() typed with a createZodDto class; the custom exception factory
  keeps TREK's { error: 'field: message; ...' } envelope byte-identical.
- The 7 piped controllers (collections, places, vacay, todo, health,
  airtrail x2) now type bodies with colocated <domain>.dto.ts wrappers —
  the Zod schemas in @trek/shared stay the single source of truth.
- OpenAPI: the hand-rolled api-zod.ts enricher is replaced by nestjs-zod's
  cleanupOpenApiDoc; request bodies surface as $refs to named DTO schemas.
- validateBodyContracts() runs in buildApp() after init and refuses to boot
  when any POST/PUT/PATCH @Body() (including @Body('field') sub-reads) lacks
  a ZodDto metatype. The 175 pre-existing legacy handlers are grandfathered
  in body-contract-allow-list.ts; the list is ratchet-only — the gate also
  throws on stale entries, so migrating a domain forces their removal.
2026-07-26 14:55:13 +02:00
jubnl 8e6b3a424e style(shared): apply eslint --fix line wrapping to locale and schema files 2026-07-26 14:54:59 +02:00
jubnl 2fa075a076 fix(shared): align todo contracts with the wire format the client sends
The optional metadata fields (category, due_date, description,
assigned_user_id — plus priority on update) now accept explicit null: the
client clears fields by sending null, which the schemas rejected. checked
accepts the legacy 0/1 numeric form alongside boolean (the integration net
pins it). Drops the client-side casts that papered over the too-narrow
types.
2026-07-26 14:54:53 +02:00
jubnl ad7d349c85 fix(todo): wrap category-assignee replacement in a transaction
updateCategoryAssignees ran its DELETE + INSERT OR IGNORE loop unwrapped, so
a mid-loop failure could leave a category half-cleared. Also widens the item
params to the nullable shapes the wire contract actually sends (prep for the
todo body validation).
2026-07-26 14:54:38 +02:00
jubnl 79946cc711 chore: move todo backend to proper DI 2026-07-26 14:09:54 +02:00
Maurice b5ebffa115 fix(dashboard): keep a trip the hero fell back to in the grid
The hero picks the trip that is running, else the next one coming up, else
just trips[0] — and that pick was then removed from the grid below. The first
two cases are deliberate: the trip is already on screen, showing it again
right underneath would be noise. The trips[0] fallback is a different thing.
It fires precisely when nothing qualifies, so it borrows a trip that belongs
in the grid, and removing it there leaves the user with a hero and an empty
list. Someone whose trips are all finished sees "No trips yet" under a card
of the trip they just came back from.

Split the two: only a trip that earned the hero is taken out of the grid.
Undated trips were caught by this too, and worse — getTripStatus returns
null rather than 'past' for them, so a lone dateless trip appeared in no
tab at all.

The empty state was the second half of the report. It equated "planned is
empty" with "you have no trips" and told people to create their first one.
Gate it on there being no trip at all, which is the condition the mobile
dashboard already uses.

Mobile shares useDashboard, so it is fixed along with the desktop.

Closes #1706
2026-07-26 12:45:54 +02:00
jubnl 2ce40724d7 chore: move category backend to proper DI 2026-07-26 12:36:30 +02:00
Maurice c8369bd14b fix(plugin): clear brokered session state on logout
sessionStorage outlives a logout inside the same tab, so the keys stay readable
after the next user signs in. They are namespaced by user id, which stops the new
user's plugins from reading them, but the data is still sitting in the tab — the
same reason logout already drops the appearance snapshot, the SW caches and the
user-scoped offline DB. Purge them there too.

Three things around it, from reading the handlers next to the new ones:

- The inactive-plugin purge ran before the store was updated, so a sessionStorage
  failure cost us the plugin list we had just fetched. Commit first, purge after.
- The session handlers answered without checking requestId, unlike confirm and
  geolocation — an answer the frame can never match is worth skipping.
- The mock host threw plain Errors while the real bridge rejects with .code set,
  and checked the key before the trip scope where the host does the reverse, so a
  plugin's error handling could pass its tests and still break in the app.
2026-07-26 11:48:15 +02:00
geracobo 2068133de2 feat(plugins): add quota-limited session storage API 2026-07-26 11:48:15 +02:00
geracobo cc8e4d480e fix(plugins): corrected non-sequential test numbers for PluginFrame 2026-07-26 11:48:15 +02:00
geracobo 9d8d0b1653 feat(plugins): add session storage api for plugin bridge 2026-07-26 11:48:15 +02:00
Maurice 0fc04909a8 docs(plugin): spell out what blurBookingCodes does, and preview it
The context table listed the new field without saying what a plugin is meant to
do with it. It is a display hint — booking codes still arrive in full over
trek:invoke — and reading it as redaction is the one mistake worth preventing.

The dev preview hardcoded it to false, so the blurred path was the only host flag
an author could not try out; it gets a toggle like reduce motion and the rest.
2026-07-26 11:48:06 +02:00
geracobo 16793761be fix(plugin): include blur booking codes on the plugin context 2026-07-26 11:48:06 +02:00
Maurice c4f4fdd794 fix(booking-import): cover the AI retry and keep its source files
The widget's new healthApi.features() call left BackgroundTasksWidget.test.tsx
red — the api/client mock never exported healthApi, so both existing tests threw
on mount. The mock now carries it, pending by default like the poll backstop, and
the retry itself finally has tests.

Writing them turned up two gaps. The retry never called saveImportFiles, so the
files only lived in memory: reloading during the retry parse left the review with
no source document to attach to the bookings it creates, unlike every import
started from the modal. And the button stayed live while the request was in
flight, so a second click started a second force-ai job on the same files.
2026-07-26 11:47:59 +02:00
Xre0uS 6854436c24 feat(booking-import): retry a failed import with AI parsing 2026-07-26 11:47:59 +02:00
Maurice eae2ade740 fix(booking-import): only resolve leg days from local_date on a prefill
Resolving every waypoint day from endpoint.local_date also changed the edit path,
where it inverts the precedence the form uses everywhere else: local_date is
denormalised and the server only restamps it in the day_id branch of insertDay,
and only when reservation_time is set. Dragging a booking to another day rewrites
day_id/end_day_id and leaves the endpoints alone, so local_date points at the old
day — and reopening the form and pressing Update silently moved the booking back.

Gate the lookup on a prefill, where there is no saved day to lose, and the edit
path behaves exactly as it did before. Same change in the mobile sheet, which
shares the seeding code and would otherwise still show empty day selectors on an
imported booking.
2026-07-26 11:47:52 +02:00
Xre0uS 68ebc700cb fix(booking-import): pre-fill leg dates from endpoint local_date 2026-07-26 11:47:52 +02:00
jubnl 8f3ee53841 feat(mcp): Create nest wrapper for MCP for proper DI (#1708) 2026-07-26 11:42:47 +02:00
Maurice 828c1db489 fix(offline): start the tile prefetch once the app is idle
syncAll runs straight off the back of a successful login, while the app
is still mounting its first screen. Kicking off a bulk background
download at that exact moment is the worst possible timing even with the
prefetch throttled, so hold the tiles back until requestIdleCallback
says the browser has nothing better to do.

Files keep their old behaviour — they are a handful of requests, not
thousands.
2026-07-26 10:54:22 +02:00
Maurice 3c67afeade fix(offline): throttle the map tile prefetch
The prefetch dispatched every tile of a trip's bounding box in one
synchronous loop — up to MAX_TILES per trip. A trip spanning a few
countries enumerates thousands of tiles at zoom 10 alone, so logging in
fired ~8k no-cors requests back to back and kept them coming for half a
minute. All of them go through the Workbox fetch handler, which then has
to write a cache entry plus an expiration record per tile, so everything
the app itself requested queued up behind the burst and the UI just sat
there.

Run the queue through a small worker pool instead, and let it stop early
when the user goes offline or logs out.

Two things fall out of that:

- Tiles already in Cache Storage are now skipped before the request is
  made, so a resumed or repeated prefetch over a warm cache is nearly
  free.
- {s} has to be a pure function of the tile coordinates for that lookup
  to hit. The rotating counter gave the same tile a different host on
  every run, which also meant caching it up to four times.
2026-07-26 10:54:22 +02:00
Maurice a3e2ff9fa5 fix(mobile): offer the currency on the dashboard trip sheet
The mobile create/edit sheet never showed the currency, so every trip
started from the dashboard silently took the server default of EUR and
could only be corrected from the trip planner, which reuses TripFormModal
and does have the field.

Same control as the desktop form — searchable select over currenciesWith(),
gated on trip_edit like the rest of the sheet.
2026-07-25 22:04:03 +02:00
jubnl 53e884ad55 refactor(nest): migrate tagService into TagsService DI (pilot)
Move the tag SQL from src/services/tagService.ts into TagsService over an
injected DatabaseService (identical statements, #10b981 default, COALESCE
semantics). Non-Nest consumers (MCP tools, plugin RPC host) go through the
new tags.bridge.ts, which exports the legacy function names 1:1 from a
module-level instance over the shared db Proxy — the settled pattern for
the remaining legacy-service migrations (recipe documented in
src/nest/README.md). Tests are DI-native: direct service construction in
unit (with bridge delegation cases for the coverage gate) and
DatabaseModule + real SQL in e2e.
2026-07-25 18:36:17 +02:00
jubnl dd2c2d6668 refactor(nest): inject DatabaseService in db-using controllers 2026-07-25 18:15:15 +02:00
jubnl fbe200424d refactor(nest): inject DatabaseService in db-using services 2026-07-25 18:15:04 +02:00
jubnl def6b84938 refactor(nest): inject DatabaseService in access-check services and controllers 2026-07-25 18:14:53 +02:00
jubnl 718022c7ba feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface 2026-07-25 18:14:37 +02:00
jubnl 7059b686ac Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 17:02:55 +02:00
jubnl 45c1568e7c chore: correct user setting currency i18n 2026-07-25 17:02:32 +02:00
Maurice c7a67ed58a fix(build): align the musl sharp pins with the sharp version
The bump to sharp 0.35.0 left the @img/sharp-linuxmusl-* pins on 0.35.1,
so npm nested the 0.35.0 binary under node_modules/sharp while hoisting
libvips to the root. The nested .node then looks for libvips-cpp.so.8.18.3
next to itself, finds nothing, and the Alpine image build dies in the
client prebuild:

  Could not load the "sharp" module using the linuxmusl-x64 runtime
  ERR_DLOPEN_FAILED: Error loading shared library libvips-cpp.so.8.18.3

Neither docker workflow runs on push, so this only surfaces when a release
is cut. Pin both binaries to 0.35.0 so they hoist next to libvips again.
2026-07-25 16:46:29 +02:00
jubnl 8a46c56a96 test(client): dismiss the CTA-style support modal in e2e setup 2026-07-25 16:30:33 +02:00
jubnl e162da6dbe fix(server): resolve zh-TW default language, close lint gap on bracket env access 2026-07-25 16:19:15 +02:00
jubnl f539c2aab7 Merge remote-tracking branch 'origin/dev' into dev 2026-07-25 16:06:29 +02:00
jubnl 0f3a6f80b7 chore(server): forbid direct process.env access outside app-config 2026-07-25 16:05:26 +02:00
jubnl 3b31f4256b refactor(server): move language/session parsing into app-config, config.ts becomes keys module 2026-07-25 16:01:42 +02:00
jubnl fb9abdb76c refactor(server): non-DI islands read env via app-config 2026-07-25 15:58:02 +02:00
jubnl 7f795a2199 refactor(server): legacy services read env via app-config 2026-07-25 15:37:37 +02:00
jubnl d13758221e refactor(server): remaining nest domains read env via app-config 2026-07-25 15:29:13 +02:00
jubnl 9e87e09d03 refactor(server): auth, oidc and admin nest modules read env via app-config 2026-07-25 15:21:17 +02:00
jubnl a34ab288f5 refactor(server): bootstrap and middleware read config via app-config 2026-07-25 15:17:33 +02:00
jubnl e17f165e44 feat(server): add nest AppConfigModule bound to @nestjs/config 2026-07-25 15:12:38 +02:00
jubnl 48ba9092c3 feat(server): add app-config core with fail-fast env validation 2026-07-25 15:03:27 +02:00
Maurice 39d4dc2675 feat(vacay): thin the mobile person card down to the number you act on
Used, carried over, comp days and remaining all fit a desktop sidebar; on a
phone they collapsed into four scraps of 9px text fighting for one row. Only
the balance is left, as a pill tinted by how much of it there is — the rest is
still a tap away in the entitlement view.

The toolbar chip says "Company" rather than "Company Holiday": it sits beside
two other buttons and the building icon already carries the meaning. The
allowance stepper reads "Days" instead of "days / year", reusing the label the
desktop entitlement tile has always used — the year is on the pill above it.
2026-07-25 14:49:38 +02:00
Maurice bccf4284c4 i18n(login): drop the keys the feature tiles used
login.features.* and login.selfHosted across all 23 locales — nothing renders
them any more.
2026-07-25 14:49:38 +02:00
Maurice 8b6a80fce0 feat(login): rebuild the branding panel and the sign-in moment around it
The panel dropped the eight feature tiles and the self-hosted line — they read
as a spec sheet on a screen whose job is to get you in. What is left is the
mark, the tagline and one line of copy, centred over the map, each with a soft
dark halo so the dots cannot eat the type. The tagline and subtitle never wrap:
they scale with the panel instead, since it is a fixed share of the viewport.

The takeoff after sign-in used to tell a different story than the screen it
came from — cloud ellipses, a lone plane, a contrail. It now picks the panel up
and escalates it: the same map, every route departing at once, the logo landing
as the network completes, then a soft hand-off into the app.
2026-07-25 14:49:38 +02:00
Maurice 45ce365920 feat(login): a living world map behind the sign-in panel
Replaces the drifting plane silhouettes with the thing TREK actually does:
routes lighting up between cities across a dot map of the world. Arcs leave a
city, a light travels the curve, the destination lands, and the arc retires to
make room for the next.

The map is drawn once into an offscreen layer and blitted per frame, so only
the handful of live arcs cost anything — no shadowBlur in the frame path. The
same component covers the sign-in moment via `variant="takeoff"`, where the
network fires as one cascade and stays lit instead of cycling.

Reduced motion gets the map as a still frame.
2026-07-25 14:49:38 +02:00
Maurice 0d9032224d feat(login): bake TREK's coastlines into a dot grid for the sign-in screen
The login screen is unauthenticated, so it cannot call the Atlas endpoint the
rest of the app uses for country geometry. This is that same admin-0 bundle,
sampled along the coastlines onto a 300x150 grid and delta-encoded — 15 KB
instead of a 28 MB fetch nobody is allowed to make yet.
2026-07-25 14:49:38 +02:00
dependabot[bot] b955998905 chore(deps-dev): bump sharp from 0.33.5 to 0.35.0 (#1690)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump sharp from 0.33.5 to 0.35.0

Bumps [sharp](https://github.com/lovell/sharp) from 0.33.5 to 0.35.0.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.33.5...v0.35.0)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:14:41 +02:00
dependabot[bot] 50c0864bce chore(deps): bump hono from 4.12.26 to 4.12.32 (#1692)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps): bump hono from 4.12.26 to 4.12.32

Bumps [hono](https://github.com/honojs/hono) from 4.12.26 to 4.12.32.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.12.26...v4.12.32)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.12.32
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:12:58 +02:00
dependabot[bot] 60f888dc71 chore(deps): bump body-parser from 1.20.5 to 1.20.6 (#1693)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump body-parser from 1.20.5 to 1.20.6

Bumps [body-parser](https://github.com/expressjs/body-parser) from 1.20.5 to 1.20.6.
- [Release notes](https://github.com/expressjs/body-parser/releases)
- [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/body-parser/compare/1.20.5...1.20.6)

---
updated-dependencies:
- dependency-name: body-parser
  dependency-version: 1.20.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:11:09 +02:00
dependabot[bot] 3b98721a64 chore(deps): bump shell-quote and concurrently (#1691)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump shell-quote and concurrently

Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together.


Updates `shell-quote` from 1.8.4 to 1.9.0
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.4...v1.9.0)

Updates `concurrently` from 10.0.3 to 10.0.4
- [Release notes](https://github.com/open-cli-tools/concurrently/releases)
- [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.9.0
  dependency-type: indirect
- dependency-name: concurrently
  dependency-version: 10.0.4
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:09:45 +02:00
dependabot[bot] 8bf8da18f6 chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk (#1688)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Revert "chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)" (#1694)

This reverts commit 94736112cf.

* chore(deps-dev): bump postcss from 8.5.16 to 8.5.23 in /plugin-sdk

Bumps [postcss](https://github.com/postcss/postcss) from 8.5.16 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.16...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: jubnl <66769052+jubnl@users.noreply.github.com>
2026-07-25 13:08:16 +02:00
dependabot[bot] 65913975d7 chore(deps): bump fast-uri from 3.1.2 to 3.1.4 (#1689)
* chore(deps): bump dompurify from 3.4.11 to 3.4.12 (#1681)

Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.11 to 3.4.12.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.11...3.4.12)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump fast-uri from 3.1.2 to 3.1.4

Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.4.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.4)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 13:05:51 +02:00
jubnl f1489534bb chore: bump dompurify to 3.4.12 2026-07-25 13:02:27 +02:00
Maurice fbd2d17fa8 docs(vacay): document the leave year and the comp/half modifiers
The page still described Vacay as strictly January to December and never
mentioned half days at all, so both new features would have shipped
undocumented.
2026-07-25 12:02:16 +02:00
Maurice 3c28c7eaeb test(vacay): cover the leave-year window and the comp exclusion
Windows for all three year types, the fallbacks (anniversary without a hire
date, a day the month cannot have), usage counted across the calendar-year
boundary, carry-over chained over periods rather than years, deleteYear over a
shifted period, and the reads with and without a viewer.

For comp: excluded from usage, a half comp day still costing nothing,
converting a vacation day back, and comp_used reported on its own.
2026-07-25 12:02:16 +02:00
Maurice 59128566d8 i18n(vacay): translate the leave-year and comp keys 2026-07-25 12:02:16 +02:00
Maurice b37996ec39 feat(vacay): make the toolbar show the marker it places (#1074)
The half-day button carried a ½ glyph and comp/flex a clock, neither of which
appears anywhere on the calendar. Both now show what a click actually puts on
the day: the orange dot for a half day, a hatched disc in the selected person's
colour for comp.

The digit on an all-comp cell also needed help. The hatch lets the surface
through between its stripes, so a white number sank into it — a shadow carries
the contrast now and every logged day keeps the same digit colour. Mixed days
still have a solid segment under the number and get none.
2026-07-25 12:02:16 +02:00
Maurice f1d520bbc2 feat(vacay): show comp days in the stats and fold the card away (#1074)
The server has returned comp_used since the leave type landed, but nothing
showed it — comp days simply vanished from the sidebar. They now sit beside
the tiles rather than inside the used/left arithmetic, which is the point of
them.

Carry-over is labelled from the window the server counted that row over, not
from the viewer's own setting, so a fused plan with mixed leave years does not
tell one member their days came "from 2025" when they came from a period that
started in July.

The card also folds now. It is the longest thing in the sidebar once several
people are fused in, and the state belongs to the person looking at it, so it
lives in localStorage rather than on the plan.
2026-07-25 12:02:16 +02:00
Maurice d984e09473 feat(vacay): pick a leave year and roll the grid over with it (#737)
Calendar, fiscal (month + day) or from the hire date, per user — it sits with
the plan settings but belongs to the person, so fused members each keep their
own and the grid follows whoever is looking at it.

Both grids render twelve months from the window start with year rollover
instead of a fixed Jan–Dec, and the mobile month navigation moves through slots
of that window, so stepping past December lands in January of the same period
rather than jumping back to the start of the calendar year. The month effect
keys on the window's shape rather than the settings object, which the store
replaces with an equal copy on every refresh — otherwise any sync would throw
away the month the user had open.

Known edge for a start day past the 1st: the cards are month-aligned, so the
first days of the start month are drawn but count toward the previous period.
Counting stays day-exact.
2026-07-25 12:02:16 +02:00
Maurice e1c5daeccb feat(vacay): mirror the leave-year window in the client (#737)
The server owns the arithmetic entitlements are computed with, but the grid has
to render the same twelve months and the overlays have to filter against the
same range, so the window resolves on both sides. yearWindow.ts is that mirror,
including the Feb-29 clamp and the anniversary-without-a-hire-date fallback.

The store loads the settings before the year list picks a default — with a
shifted year the current period is not necessarily the current calendar year.
Holidays are fetched per calendar year the window touches and clipped back to
what the grid draws; a school break straddling New Year comes back from both
fetches, so markers are de-duplicated per day. The label is part of that
identity, otherwise two calendars left on the same colour collapse into one.
2026-07-25 12:02:16 +02:00
Maurice 9d93ed95bf feat(vacay): read and write the leave year over its own window (#737)
Adds GET/PUT /addons/vacay/year-settings and makes the reads window-aware, so
the backend from the previous commit is actually reachable.

Entries and shared calendars now load over the caller's window instead of a
date prefix — a shifted year spans two calendar years and the prefix silently
dropped the second half. Reads are month-aligned (the grid draws month cards),
while entitlement stays day-exact; the two coincide for every window starting
on the 1st, which is all of calendar. deleteYear clears entries per author over
that author's period, and company holidays only over the range every member
shares, so one member deleting a period cannot reach into another's live one.
getStats reports the window it counted, and getOwnPlan seeds the period today
falls into rather than the calendar year.

A start day the month cannot have (Feb 29 from a hire date, a stored Feb 31)
is clamped to one every year has — otherwise the boundary string matches no
date and the whole window slides. Anniversary before a hire date is entered
resolves to the calendar year instead of borrowing a month a previous fiscal
setting left behind.

MCP reads pass their user through; without one the range stays the plain
calendar year, so plan-scoped callers are unchanged.
2026-07-25 12:02:16 +02:00
Maurice 2672a7a741 test(vacay): catch the suites up with the comp/flex column
The kind column changed four things the tests still asserted the old way:
toggleEntry gained a parameter, the toolbar gained the comp modifier, shared
calendar rows carry kind, and a multi-person cell is now segment overlays
instead of one split gradient. splitBackground went with it — nothing but the
test still referenced it.

The toolbar tests picked the half-day button by index, which the new button
shifted; they select by name now so the next modifier does not break them.
2026-07-25 12:02:16 +02:00
Maurice c475e5b04c feat(vacay): comp/flex leave type (#1074) + configurable-year backend (#737)
Comp/Flex days (#1074) — complete. A second leave type that does not deduct from the vacation entitlement (flextime / overtime offset). New kind column on vacay_entries, excluded from usedDays via a CASE sum; toggleEntry carries kind through service/nest/store with change-detection; desktop and mobile get a comp toolbar toggle next to the half-day one (orthogonal, so a half comp day is free); comp segments render as a diagonal hatch of the person colour, per-segment for split days (Version B). comp_used is surfaced separately in getStats. Translated into all 23 locales.

Configurable vacation year (#737) — backend only. Per-user vacay_user_settings (calendar / fiscal / anniversary), resolveYearWindow, and usedDays / compUsedDays / getStats / carry-over now computed over the leave-year [start,end) window. 'calendar' resolves byte-identically to the old date-prefix behaviour, so nothing changes for existing users and the feature is inert until the settings UI lands. Frontend (settings selector + calendar window rollover), the settings endpoint, window-aware getEntries and holiday loading are still to do — see the PR description.
2026-07-25 12:02:16 +02:00
Konstantinos Thermos c61203e607 fix(reservations): restrict all booking dates to the trip span 2026-07-24 19:16:55 +02:00
Maurice 8769ed084e refactor(reservations): move desktop traveler assignment into the edit/create modals
The always-on card picker is gone — travelers are assigned in the booking and transport modals now, matching mobile. The card shows assigned travelers read-only as pills in the grey field box, TravelerChips is replaced by a shared TravelerPicker, and the toolbar filter gets gradient avatars with an accent ring and check badge.
2026-07-24 16:50:41 +02:00
Maurice 2a5c54c025 feat(reservations): traveler filter on the mobile bookings and transports tabs 2026-07-24 16:50:41 +02:00
Maurice bc06125d6e test(reservations): server + client coverage for traveler assignment 2026-07-24 16:50:41 +02:00
Maurice e89dae5528 feat(reservations): traveler filter + transit-card badge (desktop) and full mobile traveler UI (cards + sheet pickers) 2026-07-24 16:50:41 +02:00
Maurice 8d7753c2e1 i18n(reservations): translate the traveler keys into all locales 2026-07-24 16:50:41 +02:00
Maurice 781c693434 feat(reservations): traveler badge and inline member/guest picker on booking cards (desktop) 2026-07-24 16:50:41 +02:00
Maurice 21456b983e feat(reservations): assign trip members and guests to bookings — backend (schema, migration, travelers endpoint) 2026-07-24 16:50:41 +02:00
1693 changed files with 182234 additions and 38457 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ 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 -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
+72 -2
View File
@@ -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
+1
View File
@@ -51,6 +51,7 @@ yarn-error.log*
# Coverage
coverage
coverage-*/
*.lcov
.nyc_output
+33 -12
View File
@@ -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 -575
View File
@@ -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)
![trip](./docs/screenshot-trip-mcp.png)
Please refer to the [MCP wiki](https://github.com/liketrek/TREK/wiki/MCP-Overview) for more information.
+7
View File
@@ -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>
-25
View File
@@ -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"
+7 -10
View File
@@ -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
View File
@@ -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(() => {})
}
+1 -1
View File
@@ -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",
+490
View File
@@ -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')
})
})
+843
View File
@@ -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')
})
})
+31 -13
View File
@@ -105,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 {
@@ -228,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 }
}
@@ -551,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),
@@ -931,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()
+342
View File
@@ -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)
})
})
+267
View File
@@ -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)
})
})
+96
View File
@@ -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,360 @@ describe('AddonManager', () => {
expect(() => render(<AddonManager />)).not.toThrow();
await screen.findByText('Mystery Addon');
});
it('FE-ADMIN-ADDON-012: a failing load toasts the addon error and shows the empty state', async () => {
server.use(http.get('/api/admin/addons', () => HttpResponse.error()));
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Failed to update addon');
expect(screen.getByText('No addons available')).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => {
seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } });
render(<AddonManager />);
await screen.findByText('No addons available');
expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg');
});
it('FE-ADMIN-ADDON-014: photo-flavoured trip addons are hidden from the trip section', async () => {
server.use(addonsRoute([
buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }),
buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }),
buildAddon({ id: 'todo', name: 'Todo List' }),
]));
render(<AddonManager />);
await screen.findByText('Todo List');
expect(screen.queryByText('Memories')).not.toBeInTheDocument();
expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-015: provider sub-rows carry their vendor icons and toggle state', async () => {
server.use(addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }),
buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }),
]));
render(<AddonManager />);
await screen.findByText('Immich');
// immich and synologyphotos ship a vendor glyph, unsplash does not
const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement;
expect(immichRow.querySelector('svg')).toBeInTheDocument();
const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement;
expect(synologyRow.querySelector('svg')).toBeInTheDocument();
const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement;
expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument();
expect(isOn(subToggle('Immich'))).toBe(true);
expect(isOn(subToggle('Unsplash'))).toBe(false);
});
it('FE-ADMIN-ADDON-016: toggling a photo provider persists it and refreshes the global addons', async () => {
const user = userEvent.setup();
let body: unknown = null;
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }),
]),
http.put('/api/admin/addons/immich', async ({ request }) => {
body = await request.json();
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Immich');
await user.click(subToggle('Immich'));
await waitFor(() => expect(body).toEqual({ enabled: true }));
await screen.findByText('Addon updated');
expect(isOn(subToggle('Immich'))).toBe(true);
});
it('FE-ADMIN-ADDON-017: a failing photo-provider toggle rolls the sub-row back', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([
buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }),
buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: true }),
]),
http.put('/api/admin/addons/unsplash', () => HttpResponse.error()),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Unsplash');
await user.click(subToggle('Unsplash'));
await screen.findByText('Failed to update addon');
await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true));
});
it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => {
const user = userEvent.setup();
const onToggleCollabFeature = vi.fn();
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(
<AddonManager
collabFeatures={{ chat: true, notes: false, polls: false, whatsnext: true }}
onToggleCollabFeature={onToggleCollabFeature}
/>,
);
await screen.findByText('Chat');
expect(screen.getByText('Notes')).toBeInTheDocument();
expect(screen.getByText('Polls')).toBeInTheDocument();
expect(screen.getByText("What's Next")).toBeInTheDocument();
expect(isOn(subToggle('Chat'))).toBe(true);
expect(isOn(subToggle('Notes'))).toBe(false);
await user.click(subToggle('Polls'));
expect(onToggleCollabFeature).toHaveBeenCalledWith('polls');
});
it('FE-ADMIN-ADDON-019: collab sub-features stay hidden without the handler props', async () => {
server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })]));
render(<AddonManager />);
await screen.findByText('Collab');
expect(screen.queryByText('Polls')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => {
server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }]));
render(<AddonManager />);
await screen.findByText('AI Parsing');
expect(screen.getByText('Extract bookings from files')).toBeInTheDocument();
expect(screen.queryByText('Connection')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
await screen.findByRole('button', { name: 'llama3:8b' });
expect(urls[0]).toBe('http://localhost:11434/v1');
await user.click(screen.getByRole('button', { name: 'llama3:8b' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b');
// qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull"
await user.click(screen.getByRole('button', { name: 'Use' }));
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled();
});
it('FE-ADMIN-ADDON-022: an unreachable Ollama shows the error and Refresh retries', async () => {
const user = userEvent.setup();
let calls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
calls += 1;
return calls === 1
? HttpResponse.json({ error: 'down' }, { status: 500 })
: HttpResponse.json({ models: [] });
}),
);
render(<AddonManager />);
await screen.findByText(/Request failed with status code 500/);
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('No models installed yet — pull one below.');
expect(calls).toBe(2);
});
it('FE-ADMIN-ADDON-023: switching providers swaps the base URL field, the model hint and the Ollama block', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
// A hand-typed base URL is used for the next lookup on blur
await user.type(screen.getByPlaceholderText('http://localhost:11434/v1'), 'http://ollama.lan:11434/v1');
await user.tab();
await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1'));
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument();
expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument();
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument();
expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument();
});
it('FE-ADMIN-ADDON-024: pulling a model streams progress and then selects it', async () => {
const user = userEvent.setup();
let pulled: unknown = null;
let modelCalls = 0;
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
http.get('/api/admin/llm/local/models', () => {
modelCalls += 1;
return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] });
}),
http.post('/api/admin/llm/local/pull', async ({ request }) => {
pulled = await request.json();
await delay(150);
return new HttpResponse(
'{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
);
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('Pulling…');
expect(screen.getByText('starting…')).toBeInTheDocument();
await screen.findByText('Model pulled');
expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' });
expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b');
await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled());
});
it('FE-ADMIN-ADDON-025: a failing pull surfaces the server error and saving reports both outcomes', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error();
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('no disk space');
expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
expect(bodies[0]).toEqual({
config: { provider: 'local', model: 'qwen3:8b', baseUrl: '', apiKey: '••••••••', multimodal: true },
});
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Failed to save');
});
it('FE-ADMIN-ADDON-026: model and API key are editable and their hints follow the provider', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
expect(screen.getByPlaceholderText('(often not required)')).toBeInTheDocument();
await user.type(screen.getByPlaceholderText('select or pull below'), ' mistral:7b ');
await user.type(screen.getByPlaceholderText('(often not required)'), 'sk-live');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'OpenAI' }));
expect(screen.getByPlaceholderText('sk-…')).toHaveValue('sk-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The model is trimmed before it is stored, the key is sent verbatim
expect(bodies[0]).toEqual({
config: { provider: 'openai', model: 'mistral:7b', baseUrl: '', apiKey: 'sk-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-029: switching to Anthropic clears a stale base URL before saving', async () => {
const user = userEvent.setup();
const bodies: unknown[] = [];
server.use(
addonsRoute([llmAddon({ provider: 'local', model: '', baseUrl: 'http://ollama.lan:11434/v1', apiKey: '', multimodal: false })]),
modelsRoute([]),
http.put('/api/admin/addons/llm_parsing', async ({ request }) => {
bodies.push(await request.json());
return HttpResponse.json({ success: true });
}),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('Installed on the server');
await user.click(screen.getByRole('button', { name: /Local · OpenAI-compatible/ }));
await user.click(screen.getByRole('button', { name: 'Anthropic' }));
await user.type(screen.getByPlaceholderText('claude-opus-4-8'), 'claude-haiku-4-5-20251001');
await user.type(screen.getByPlaceholderText('sk-…'), 'sk-ant-live');
await user.click(screen.getByRole('button', { name: 'Save' }));
await screen.findByText('Saved');
// The stale local base URL must not ride along to Anthropic — it would hijack the endpoint.
expect(bodies[0]).toEqual({
config: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', baseUrl: '', apiKey: 'sk-ant-live', multimodal: false },
});
});
it('FE-ADMIN-ADDON-027: an error frame in the pull stream aborts the pull and is reported', async () => {
const user = userEvent.setup();
server.use(
addonsRoute([llmAddon({ provider: 'local' })]),
modelsRoute([]),
http.post('/api/admin/llm/local/pull', () => new HttpResponse(
'{"status":"pulling manifest"}\n{"error":"manifest not found"}\n',
{ headers: { 'Content-Type': 'application/x-ndjson' } },
)),
);
render(<><ToastContainer /><AddonManager /></>);
await screen.findByText('No models installed yet — pull one below.');
await user.click(screen.getByRole('button', { name: 'Pull' }));
await screen.findByText('manifest not found');
expect(screen.queryByText('Model pulled')).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled());
expect(screen.queryByText('Pulling…')).not.toBeInTheDocument();
});
it('FE-ADMIN-ADDON-028: blurring the base URL under a cloud provider queries no local models', async () => {
const user = userEvent.setup();
const urls: (string | null)[] = [];
server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls));
render(<AddonManager />);
await screen.findByText('Connection');
expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1');
await user.tab();
await waitFor(() => expect(screen.getByDisplayValue('https://proxy.local/v1')).toBeInTheDocument());
expect(urls).toHaveLength(0);
});
});
+1 -1
View File
@@ -399,7 +399,7 @@ function LlmParsingConfig({ addon }: { addon: Addon }) {
setSaving(true)
try {
// Send the masked sentinel unchanged so the server keeps the stored key.
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
await adminApi.updateAddon(addon.id, { config: { provider, model: model.trim(), baseUrl: provider === 'anthropic' ? '' : baseUrl.trim(), apiKey, multimodal: cfg.multimodal === true } })
toast.success('Saved')
} catch {
toast.error('Failed to save')
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -300,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. */
@@ -1149,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
@@ -1401,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
@@ -1425,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>
@@ -1451,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">
@@ -1460,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>
@@ -1470,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
@@ -1493,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 members 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
+3 -7
View File
@@ -11,6 +11,7 @@ import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
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'
@@ -319,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 ────────────────────────────────────────
@@ -763,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>
@@ -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')
})
})
@@ -218,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();
});
});
+3 -2
View File
@@ -271,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)'}>
@@ -526,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)
})
})
@@ -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()
})
})
+5 -5
View File
@@ -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();
}
});
});
+15 -9
View File
@@ -26,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
@@ -59,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
}
@@ -80,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) }
}
@@ -96,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 */}
@@ -206,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={{
@@ -232,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>
@@ -269,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,
@@ -280,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 */}
@@ -472,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 => (
@@ -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)
})
})
@@ -192,7 +192,7 @@ export default function AddPlaceToCollectionModal({ isOpen, collectionId, collec
<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} placeholder={t('places.formLng')} className={coordInputClass} />
<NumericInput mode="signed" value={lng} onValueChange={setLng} onPaste={coordPaste} placeholder={t('places.formLng')} className={coordInputClass} />
</div>
</div>
@@ -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();
});
});
@@ -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();
@@ -142,8 +161,17 @@ describe('CollectionPlaceDetail', () => {
// ── 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() });
expect(await screen.findByRole('button', { name: 'Upload image' })).toBeInTheDocument();
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 () => {
@@ -161,4 +189,304 @@ describe('CollectionPlaceDetail', () => {
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);
});
});
@@ -15,7 +15,7 @@ import { useToast } from '../shared/Toast'
import { Tooltip } from '../shared/Tooltip'
import PlaceRating from '../shared/StarRating'
import { normalizeImageFile } from '../../utils/convertHeic'
import { getApiErrorMessage } from '../../types'
import { getApiErrorMessage } from '../../utils/apiError'
function linkHost(url: string): string {
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
@@ -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,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()
})
})
@@ -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,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 dont 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)
})
})
+17 -5
View File
@@ -114,10 +114,20 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
cv.height = Math.round(cv.clientHeight * dpr)
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
scene.layout(cv.clientWidth, cv.clientHeight)
// The finale samples the title rect in screen coordinates, so it has to be
// re-sampled against the new geometry.
assemblyRef.current = null
}
fit()
if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal)
window.addEventListener('resize', fit)
// layout() rebakes every static layer, which is far too heavy for the event
// storm a window drag produces — coalesce to the end of the gesture.
let refitTimer = 0
const onResize = () => {
window.clearTimeout(refitTimer)
refitTimer = window.setTimeout(fit, 150)
}
window.addEventListener('resize', onResize)
// The traveler's own places, gathered quietly during the fear act. Fail-soft:
// without them the show falls back to its generic lines.
void (async () => {
@@ -163,7 +173,8 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
if (reducedMotion) skipToEnd()
return () => {
document.body.classList.remove('fz-show-open')
window.removeEventListener('resize', fit)
window.clearTimeout(refitTimer)
window.removeEventListener('resize', onResize)
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('pointerdown', onPointer)
aborter.abort()
@@ -191,10 +202,11 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
if (idx !== lastCue && idx >= 0) {
// A replaced fear-act line decays letter by letter instead of vanishing.
if (lastCue >= 0 && lastCue <= 3) {
const prevKey = CUES[lastCue].line
const ghostId = lastCue // pin it — lastCue advances on the next line
const prevKey = CUES[ghostId].line
if (prevKey) {
setGhost({ text: copy.lines[prevKey], id: lastCue })
window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400)
setGhost({ text: copy.lines[prevKey], id: ghostId })
window.setTimeout(() => setGhost(g => (g?.id === ghostId ? null : g)), 1400)
}
}
lastCue = idx
@@ -0,0 +1,346 @@
// FE-NOFEAR-ASM-001 to FE-NOFEAR-ASM-017
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { TextAssembly } from './noFearAssembly'
interface FillRecord {
fillStyle: string
globalAlpha: number
composite: string
x: number
y: number
r: number
}
interface FakeCtx {
font: string
textAlign: string
textBaseline: string
fillStyle: string
globalAlpha: number
globalCompositeOperation: string
save: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
fillText: ReturnType<typeof vi.fn>
arc: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
getImageData: ReturnType<typeof vi.fn>
fills: FillRecord[]
}
// jsdom has no canvas backend, so every 2d context in these tests is a recorder.
// getImageData replays `alphaAt` so the sampled letterform is fully deterministic.
let alphaAt: (x: number, y: number) => number = () => 0
let contextAvailable = true
let contexts: FakeCtx[] = []
const originalGetContext = HTMLCanvasElement.prototype.getContext
function makeCtx(): FakeCtx {
let lastArc: { x: number; y: number; r: number } | null = null
const ctx: FakeCtx = {
font: '',
textAlign: '',
textBaseline: '',
fillStyle: '',
globalAlpha: 1,
globalCompositeOperation: 'source-over',
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
fillText: vi.fn(),
arc: vi.fn((x: number, y: number, r: number) => { lastArc = { x, y, r } }),
fill: vi.fn(() => {
const a = lastArc ?? { x: NaN, y: NaN, r: NaN }
ctx.fills.push({
fillStyle: ctx.fillStyle,
globalAlpha: ctx.globalAlpha,
composite: ctx.globalCompositeOperation,
x: a.x,
y: a.y,
r: a.r,
})
}),
getImageData: vi.fn((_x: number, _y: number, w: number, h: number) => {
const data = new Uint8ClampedArray(w * h * 4)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alphaAt(x, y)
}
return { data }
}),
fills: [],
}
return ctx
}
/** Math.random replaced by a repeating sequence — init consumes exactly 6 per particle. */
function cycleRandom(values: number[]): void {
let i = 0
vi.spyOn(Math, 'random').mockImplementation(() => values[i++ % values.length])
}
const asCtx = (c: FakeCtx) => c as unknown as CanvasRenderingContext2D
beforeEach(() => {
alphaAt = () => 0
contextAvailable = true
contexts = []
HTMLCanvasElement.prototype.getContext = vi.fn(() => {
if (!contextAvailable) return null
const c = makeCtx()
contexts.push(c)
return c
}) as unknown as HTMLCanvasElement['getContext']
})
afterEach(() => {
HTMLCanvasElement.prototype.getContext = originalGetContext
vi.restoreAllMocks()
})
const BOX = { left: 100, top: 50, width: 12, height: 12 }
describe('TextAssembly.init', () => {
it('FE-NOFEAR-ASM-001: turns every opaque raster cell into a particle aimed at its screen position', () => {
alphaAt = (x, y) => (x === 3 && y === 6 ? 200 : 0)
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const a = new TextAssembly()
a.init('KEINE ANGST', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(true)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(1)
expect(main.fills[0].x).toBeCloseTo(103, 6)
expect(main.fills[0].y).toBeCloseTo(56, 6)
})
it('FE-NOFEAR-ASM-002: rasters the text centred in the offscreen box', () => {
alphaAt = () => 0
cycleRandom([0.1])
new TextAssembly().init('KEINE ANGST', 'bold 64px Inter', BOX, 1000, 600)
const off = contexts[0]
expect(off.font).toBe('bold 64px Inter')
expect(off.textAlign).toBe('center')
expect(off.textBaseline).toBe('middle')
expect(off.fillStyle).toBe('#fff')
expect(off.fillText).toHaveBeenCalledWith('KEINE ANGST', 6, 6)
expect(off.getImageData).toHaveBeenCalledWith(0, 0, 12, 12)
})
it('FE-NOFEAR-ASM-003: caps the raster at 700px and scales the font shorthand with it', () => {
alphaAt = () => 0
cycleRandom([0.1])
new TextAssembly().init('KEINE ANGST', 'bold 200px Inter', { left: 0, top: 0, width: 1400, height: 6 }, 1000, 600)
const off = contexts[0]
expect(off.font).toBe('bold 100px Inter')
expect(off.fillText).toHaveBeenCalledWith('KEINE ANGST', 350, 1.5)
expect(off.getImageData).toHaveBeenCalledWith(0, 0, 700, 3)
})
it('FE-NOFEAR-ASM-004: maps raster coordinates back through the scale factor', () => {
alphaAt = (x, y) => (x === 6 && y === 0 ? 200 : 0)
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const a = new TextAssembly()
a.init('X', 'bold 200px Inter', { left: 0, top: 0, width: 1400, height: 6 }, 1000, 600)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(1)
// raster x 6 at scale 0.5 lands at screen x 12
expect(main.fills[0].x).toBeCloseTo(12, 6)
expect(main.fills[0].y).toBeCloseTo(0, 6)
})
it('FE-NOFEAR-ASM-005: treats alpha 128 as transparent and 129 as solid', () => {
alphaAt = (x, y) => (y === 0 && x === 0 ? 128 : y === 0 && x === 3 ? 129 : 0)
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', { left: 0, top: 0, width: 12, height: 12 }, 1000, 600)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(1)
expect(main.fills[0].x).toBeCloseTo(3, 6)
})
it('FE-NOFEAR-ASM-006: samples the raster on a 3px grid', () => {
alphaAt = () => 255
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', { left: 0, top: 0, width: 12, height: 12 }, 1000, 600)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
// 12x12 raster stepped by 3 → 4x4 sample points
expect(main.fills).toHaveLength(16)
})
it('FE-NOFEAR-ASM-007: is ready but silent when the raster is empty', () => {
alphaAt = () => 0
cycleRandom([0.1])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(true)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.save).toHaveBeenCalledTimes(1)
expect(main.restore).toHaveBeenCalledTimes(1)
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-008: stays unready when no 2d context is available', () => {
contextAvailable = false
alphaAt = () => 255
cycleRandom([0.1])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(false)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.save).not.toHaveBeenCalled()
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-019: drops the previous targets when a re-init cannot raster', () => {
alphaAt = () => 255
cycleRandom([0.1])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(true)
contextAvailable = false
a.init('X', 'bold 64px Inter', BOX, 1000, 600)
expect(a.isReady()).toBe(false)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-009: keeps a degenerate box at one raster pixel', () => {
alphaAt = () => 255
cycleRandom([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', { left: 5, top: 7, width: 0, height: 0 }, 1000, 600)
expect(contexts[0].getImageData).toHaveBeenCalledWith(0, 0, 1, 1)
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toHaveLength(1)
expect(main.fills[0].x).toBeCloseTo(5, 6)
expect(main.fills[0].y).toBeCloseTo(7, 6)
})
})
describe('TextAssembly.draw', () => {
function seeded(values: number[], box = BOX): TextAssembly {
alphaAt = (x, y) => (x === 0 && y === 0 ? 200 : 0)
cycleRandom(values)
const a = new TextAssembly()
a.init('X', 'bold 64px Inter', box, 1000, 600)
return a
}
it('FE-NOFEAR-ASM-010: does nothing before init', () => {
const main = makeCtx()
new TextAssembly().draw(asCtx(main), 0.5, 0, 0)
expect(main.save).not.toHaveBeenCalled()
})
it('FE-NOFEAR-ASM-011: bails out once the DOM title has fully taken over', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const main = makeCtx()
a.draw(asCtx(main), 1, 1, 0)
expect(main.save).not.toHaveBeenCalled()
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-012: skips particles whose delay has not elapsed', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // delay 0.045
const main = makeCtx()
a.draw(asCtx(main), 0.04, 0, 0)
expect(main.save).toHaveBeenCalledTimes(1)
expect(main.restore).toHaveBeenCalledTimes(1)
expect(main.fills).toHaveLength(0)
})
it('FE-NOFEAR-ASM-013: settled particles cool to ivory, shrink and additively blend', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // size 1.02
const main = makeCtx()
a.draw(asCtx(main), 1, 0, 0)
expect(main.fills).toEqual([
expect.objectContaining({ fillStyle: 'rgb(247, 240, 226)', composite: 'lighter' }),
])
expect(main.fills[0].globalAlpha).toBeCloseTo(0.9, 6)
expect(main.fills[0].r).toBeCloseTo(1.02 * 0.85, 6)
})
it('FE-NOFEAR-ASM-014: in-flight particles glow warm and flicker with t', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0]) // delay 0.045, size 1.02, seed 0
const main = makeCtx()
a.draw(asCtx(main), 0.5, 0, 0)
const local = 1 - (1 - (0.5 - 0.045) / 0.955) ** 3
expect(main.fills).toHaveLength(1)
expect(main.fills[0].fillStyle).toBe('rgb(255, 205, 130)')
expect(main.fills[0].r).toBeCloseTo(1.02, 6)
// sin(0) → flicker sits at its 0.65 floor
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75 * 0.65, 6)
expect(main.fills[0].x).toBeCloseTo(-30 + (100 + 30) * local, 6)
expect(main.fills[0].y).toBeCloseTo(60 + (50 - 60) * local, 6)
})
it('FE-NOFEAR-ASM-015: the flicker peaks a quarter period into the sine', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const main = makeCtx()
a.draw(asCtx(main), 0.5, 0, Math.PI / 10)
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75, 6)
})
it('FE-NOFEAR-ASM-016: fade dissolves the particle layer', () => {
const a = seeded([0.1, 0.1, 0.1, 0.1, 0.1, 0])
const main = makeCtx()
a.draw(asCtx(main), 1, 0.5, 0)
expect(main.fills[0].globalAlpha).toBeCloseTo(0.45, 6)
})
it('FE-NOFEAR-ASM-017: side entries start beyond the right edge and fly in', () => {
// fromSide true, second draw >= 0.5 → spawn at screenW + 30
const a = seeded([0.1, 0.9, 0.2, 0.4, 0.5, 0.3], { left: 0, top: 0, width: 12, height: 12 })
const main = makeCtx()
a.draw(asCtx(main), 0.18, 0, 0)
expect(main.fills).toHaveLength(0)
a.draw(asCtx(main), 0.59, 0, 0)
expect(main.fills).toHaveLength(1)
expect(main.fills[0].x).toBeCloseTo(128.75, 6)
expect(main.fills[0].y).toBeCloseTo(15, 6)
expect(main.fills[0].r).toBeCloseTo(1.5, 6)
expect(main.fills[0].globalAlpha).toBeCloseTo(0.75 * (0.65 + 0.35 * Math.sin(2.1)), 6)
})
it('FE-NOFEAR-ASM-018: ground entries rise from below the viewport', () => {
// fromSide false → sx inside the viewport, sy below screenH
const a = seeded([0.5, 0.5, 0.5, 0.5, 0.5, 0.5], { left: 0, top: 0, width: 12, height: 12 })
const main = makeCtx()
a.draw(asCtx(main), 0.6125, 0, 0)
expect(main.fills).toHaveLength(1)
expect(main.fills[0].x).toBeCloseTo(62.5, 6)
expect(main.fills[0].y).toBeCloseTo(82.5, 6)
})
})
@@ -32,6 +32,10 @@ export class TextAssembly {
off.width = Math.max(Math.round(box.width * scale), 1)
off.height = Math.max(Math.round(box.height * scale), 1)
const c = off.getContext('2d')
// A re-init that cannot raster must go quiet rather than keep the previous
// run's particles on screen at coordinates that no longer hold.
this.ready = false
this.particles = []
if (!c) return
c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`)
c.textAlign = 'center'
@@ -40,7 +44,6 @@ export class TextAssembly {
c.fillText(text, off.width / 2, off.height / 2)
const img = c.getImageData(0, 0, off.width, off.height).data
const step = 3 // sample grid in raster px — ~800-1400 particles for a title
this.particles = []
for (let y = 0; y < off.height; y += step) {
for (let x = 0; x < off.width; x += step) {
if (img[(y * off.width + x) * 4 + 3] > 128) {
@@ -0,0 +1,969 @@
// FE-NOFEAR-AUD-001 to FE-NOFEAR-AUD-043
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { NoFearAudio } from './noFearAudio'
// jsdom ships no Web Audio implementation, so the graph below is a recorder:
// every node keeps its outgoing connections, every AudioParam keeps the calls
// that were scheduled on it, and the context clock is driven by `nowMs` so the
// fake timers and the audio clock stay in lockstep.
type NodeKind =
| 'gain'
| 'oscillator'
| 'bufferSource'
| 'biquad'
| 'convolver'
| 'compressor'
| 'delay'
| 'destination'
let created: FakeNode[] = []
let contexts: FakeAudioContext[] = []
let nowMs = 0
const behavior = {
state: 'running' as AudioContextState,
resumeRejects: false,
suspendRejects: false,
closeRejects: false,
}
class FakeAudioParam {
value: number
setValueAtTime = vi.fn((value: number, _at: number) => {
this.value = value
})
linearRampToValueAtTime = vi.fn((_value: number, _at: number) => undefined)
exponentialRampToValueAtTime = vi.fn((_value: number, _at: number) => undefined)
setTargetAtTime = vi.fn((_value: number, _at: number, _timeConstant: number) => undefined)
cancelScheduledValues = vi.fn((_at: number) => undefined)
constructor(value = 0) {
this.value = value
}
}
class FakeNode {
kind: NodeKind
connections: unknown[] = []
connect: ReturnType<typeof vi.fn>
disconnect: ReturnType<typeof vi.fn>
constructor(kind: NodeKind) {
this.kind = kind
this.connect = vi.fn((target: unknown) => {
this.connections.push(target)
return target
})
this.disconnect = vi.fn(() => {
this.connections = []
})
created.push(this)
}
}
class FakeSource extends FakeNode {
started: number[] = []
stopped: number[] = []
throwOnStop = false
start = vi.fn((at = 0) => {
this.started.push(at)
})
stop = vi.fn((at = 0) => {
if (this.throwOnStop) throw new Error('InvalidStateError')
this.stopped.push(at)
})
}
class FakeOscillator extends FakeSource {
type = 'sine'
frequency = new FakeAudioParam(440)
detune = new FakeAudioParam(0)
constructor() {
super('oscillator')
}
}
class FakeBufferSource extends FakeSource {
buffer: FakeAudioBuffer | null = null
loop = false
playbackRate = new FakeAudioParam(1)
constructor() {
super('bufferSource')
}
}
class FakeGain extends FakeNode {
gain = new FakeAudioParam(1)
constructor() {
super('gain')
}
}
class FakeBiquadFilter extends FakeNode {
type = 'lowpass'
frequency = new FakeAudioParam(350)
Q = new FakeAudioParam(1)
detune = new FakeAudioParam(0)
gain = new FakeAudioParam(0)
constructor() {
super('biquad')
}
}
class FakeConvolver extends FakeNode {
buffer: FakeAudioBuffer | null = null
normalize = true
constructor() {
super('convolver')
}
}
class FakeCompressor extends FakeNode {
threshold = new FakeAudioParam(-24)
knee = new FakeAudioParam(30)
ratio = new FakeAudioParam(12)
attack = new FakeAudioParam(0.003)
release = new FakeAudioParam(0.25)
constructor() {
super('compressor')
}
}
class FakeDelay extends FakeNode {
delayTime = new FakeAudioParam(0)
maxDelayTime: number
constructor(maxDelayTime: number) {
super('delay')
this.maxDelayTime = maxDelayTime
}
}
class FakeAudioBuffer {
numberOfChannels: number
length: number
sampleRate: number
duration: number
private channels: Float32Array[]
constructor(numberOfChannels: number, length: number, sampleRate: number) {
this.numberOfChannels = numberOfChannels
this.length = length
this.sampleRate = sampleRate
this.duration = length / sampleRate
this.channels = Array.from({ length: numberOfChannels }, () => new Float32Array(length))
}
getChannelData(channel: number): Float32Array {
return this.channels[channel]
}
}
class FakeAudioContext {
state: AudioContextState
// A low rate keeps the procedurally filled impulse response and noise buffer
// small; the code only ever multiplies against it.
sampleRate = 8000
destination = new FakeNode('destination')
resume = vi.fn(() =>
behavior.resumeRejects ? Promise.reject(new Error('resume blocked')) : Promise.resolve(),
)
suspend = vi.fn(() =>
behavior.suspendRejects ? Promise.reject(new Error('suspend blocked')) : Promise.resolve(),
)
close = vi.fn(() =>
behavior.closeRejects ? Promise.reject(new Error('close failed')) : Promise.resolve(),
)
decodeAudioData = vi.fn(() => Promise.resolve(new FakeAudioBuffer(2, 16, 8000)))
constructor() {
this.state = behavior.state
contexts.push(this)
}
get currentTime(): number {
return nowMs / 1000
}
createGain(): FakeGain {
return new FakeGain()
}
createOscillator(): FakeOscillator {
return new FakeOscillator()
}
createBufferSource(): FakeBufferSource {
return new FakeBufferSource()
}
createBiquadFilter(): FakeBiquadFilter {
return new FakeBiquadFilter()
}
createConvolver(): FakeConvolver {
return new FakeConvolver()
}
createDynamicsCompressor(): FakeCompressor {
return new FakeCompressor()
}
createDelay(maxDelayTime = 1): FakeDelay {
return new FakeDelay(maxDelayTime)
}
createBuffer(numberOfChannels: number, length: number, sampleRate: number): FakeAudioBuffer {
return new FakeAudioBuffer(numberOfChannels, length, sampleRate)
}
}
// ── query helpers ────────────────────────────────────────────────────────────
function mark(): number {
return created.length
}
function since(from: number): FakeNode[] {
return created.slice(from)
}
function oscs(list: FakeNode[]): FakeOscillator[] {
return list.filter((n) => n.kind === 'oscillator') as FakeOscillator[]
}
function bufs(list: FakeNode[]): FakeBufferSource[] {
return list.filter((n) => n.kind === 'bufferSource') as FakeBufferSource[]
}
function gains(list: FakeNode[]): FakeGain[] {
return list.filter((n) => n.kind === 'gain') as FakeGain[]
}
function filters(list: FakeNode[]): FakeBiquadFilter[] {
return list.filter((n) => n.kind === 'biquad') as FakeBiquadFilter[]
}
function only<T>(list: T[]): T {
expect(list).toHaveLength(1)
return list[0]
}
function ctx(): FakeAudioContext {
return contexts[contexts.length - 1]
}
function compressor(): FakeCompressor {
return created.find((n) => n.kind === 'compressor') as FakeCompressor
}
function masterGain(): FakeGain {
const limiter = compressor()
return gains(created).find((g) => g.connections.includes(limiter)) as FakeGain
}
function reverbIn(): FakeGain {
const convolver = created.find((n) => n.kind === 'convolver')
return gains(created).find((g) => g.connections.includes(convolver)) as FakeGain
}
/** How much of `node` is sent into the hall, or undefined when it stays dry. */
function hallSend(node: FakeNode): number | undefined {
const hall = reverbIn()
const send = node.connections.find(
(target) => target instanceof FakeGain && target.connections.includes(hall),
)
return (send as FakeGain | undefined)?.gain.value
}
/** Start times of the heartbeat's sine bodies — its 58 Hz drop is the signature. */
function thumpTimes(list: FakeNode[]): number[] {
return oscs(list)
.filter((o) => o.frequency.setValueAtTime.mock.calls.some((call) => call[0] === 58))
.map((o) => o.started[0])
}
function rms(data: Float32Array, from: number, to: number): number {
let sum = 0
for (let i = from; i < to; i++) sum += data[i] * data[i]
return Math.sqrt(sum / (to - from))
}
/** Advances the audio clock and the timer queue together, in interval-sized steps. */
function advance(ms: number): void {
let left = ms
while (left > 0) {
const step = Math.min(50, left)
nowMs += step
vi.advanceTimersByTime(step)
left -= step
}
}
describe('NoFearAudio', () => {
let audio: NoFearAudio
beforeEach(() => {
vi.useFakeTimers()
created = []
contexts = []
nowMs = 0
behavior.state = 'running'
behavior.resumeRejects = false
behavior.suspendRejects = false
behavior.closeRejects = false
vi.stubGlobal('AudioContext', FakeAudioContext)
vi.stubGlobal('webkitAudioContext', undefined)
audio = new NoFearAudio()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
describe('start', () => {
it('FE-NOFEAR-AUD-001: routes master through a limiter into the destination', () => {
audio.start()
const limiter = compressor()
expect(limiter.threshold.value).toBe(-12)
expect(limiter.knee.value).toBe(22)
expect(limiter.ratio.value).toBe(12)
expect(limiter.connections).toEqual([ctx().destination])
expect(masterGain().gain.value).toBe(0.9)
expect(masterGain().connections).toEqual([limiter])
})
it('FE-NOFEAR-AUD-002: is idempotent — a second call keeps the first context', () => {
audio.start()
audio.start()
expect(contexts).toHaveLength(1)
})
it('FE-NOFEAR-AUD-003: without a Web Audio constructor every entry point stays a no-op', () => {
vi.stubGlobal('AudioContext', undefined)
audio.start()
audio.setMuted(true)
audio.resume()
audio.setSuspended(true)
audio.swell()
audio.impact()
audio.setAct('fear')
audio.dispose()
expect(contexts).toHaveLength(0)
expect(created).toHaveLength(0)
})
it('FE-NOFEAR-AUD-004: falls back to the prefixed webkitAudioContext', () => {
vi.stubGlobal('AudioContext', undefined)
vi.stubGlobal('webkitAudioContext', FakeAudioContext)
audio.start()
expect(contexts).toHaveLength(1)
expect(masterGain().gain.value).toBe(0.9)
})
it('FE-NOFEAR-AUD-005: re-arms a context the browser started suspended', () => {
behavior.state = 'suspended'
audio.start()
expect(ctx().resume).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-AUD-006: swallows a rejected resume on start', async () => {
behavior.state = 'suspended'
behavior.resumeRejects = true
expect(() => audio.start()).not.toThrow()
await Promise.resolve()
expect(ctx().resume).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-AUD-007: builds a 3.4s stereo impulse response that decays', () => {
audio.start()
const convolver = created.find((n) => n.kind === 'convolver') as FakeConvolver
const ir = convolver.buffer as FakeAudioBuffer
expect(ir.numberOfChannels).toBe(2)
expect(ir.length).toBe(Math.floor(8000 * 3.4))
const left = ir.getChannelData(0)
expect(rms(left, 0, 400)).toBeGreaterThan(rms(left, left.length - 400, left.length))
expect(rms(ir.getChannelData(1), 0, 400)).toBeGreaterThan(0)
})
it('FE-NOFEAR-AUD-008: returns the hall through a 0.5 wet gain on the master bus', () => {
audio.start()
const convolver = created.find((n) => n.kind === 'convolver') as FakeConvolver
expect(reverbIn().connections).toEqual([convolver])
const wet = gains(created).find((g) => convolver.connections.includes(g)) as FakeGain
expect(wet.gain.value).toBe(0.5)
expect(wet.connections).toEqual([masterGain()])
})
it('FE-NOFEAR-AUD-009: fills a 2s noise buffer that every texture reuses', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const noise = bufs(since(m))
expect(noise.length).toBeGreaterThan(0)
const buffer = noise[0].buffer as FakeAudioBuffer
expect(buffer.length).toBe(8000 * 2)
expect(buffer.numberOfChannels).toBe(1)
const data = buffer.getChannelData(0)
expect(rms(data, 0, 1000)).toBeGreaterThan(0.4)
expect(Math.max(...data.slice(0, 1000))).toBeLessThan(1)
expect(Math.min(...data.slice(0, 1000))).toBeGreaterThan(-1)
// all textures share the one buffer
expect(noise.every((n) => n.buffer === buffer)).toBe(true)
})
it('FE-NOFEAR-AUD-010: starts silent when the show was muted before the gesture', () => {
audio.setMuted(true)
audio.start()
expect(masterGain().gain.value).toBe(0)
})
})
describe('transport', () => {
it('FE-NOFEAR-AUD-011: setMuted ramps the master down and back up', () => {
audio.start()
const master = masterGain()
audio.setMuted(true)
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0, 0, 0.05)
advance(500)
audio.setMuted(false)
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0.9, 0.5, 0.05)
})
it('FE-NOFEAR-AUD-012: unmuting recovers a context the browser refused to start', () => {
audio.start()
ctx().state = 'suspended'
audio.setMuted(false)
expect(ctx().resume).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-AUD-013: muting never tries to resume', () => {
audio.start()
ctx().state = 'suspended'
audio.setMuted(true)
expect(ctx().resume).not.toHaveBeenCalled()
})
it('FE-NOFEAR-AUD-014: resume only touches a suspended context', () => {
audio.start()
audio.resume()
expect(ctx().resume).not.toHaveBeenCalled()
ctx().state = 'suspended'
audio.resume()
expect(ctx().resume).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-AUD-043: a browser that rejects resume/suspend never surfaces the error', async () => {
behavior.resumeRejects = true
behavior.suspendRejects = true
audio.start()
ctx().state = 'suspended'
audio.setMuted(false)
audio.resume()
audio.setSuspended(true)
audio.setSuspended(false)
await Promise.resolve()
await Promise.resolve()
expect(ctx().resume).toHaveBeenCalledTimes(3)
expect(ctx().suspend).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-AUD-015: setSuspended freezes and unfreezes with the show clock', () => {
audio.start()
audio.setSuspended(true)
expect(ctx().suspend).toHaveBeenCalledTimes(1)
expect(ctx().resume).not.toHaveBeenCalled()
audio.setSuspended(false)
expect(ctx().resume).toHaveBeenCalledTimes(1)
})
})
describe('one-shots', () => {
it('FE-NOFEAR-AUD-016: swell blooms a 55 Hz sine over 1.6s and dies at +5', () => {
audio.start()
const m = mark()
audio.swell()
const o = only(oscs(since(m)))
expect(o.type).toBe('sine')
expect(o.frequency.value).toBe(55)
expect(o.started[0]).toBeCloseTo(0.05)
expect(o.stopped[0]).toBeCloseTo(5.25)
const envelope = gains(since(m))[0]
expect(o.connections).toEqual([envelope])
const ramps = envelope.gain.exponentialRampToValueAtTime.mock.calls
expect(ramps[0][0]).toBeCloseTo(0.22)
expect(ramps[0][1]).toBeCloseTo(1.65)
expect(ramps[1][0]).toBeCloseTo(0.0001)
expect(ramps[1][1]).toBeCloseTo(5.05)
expect(hallSend(envelope)).toBe(0.6)
})
it('FE-NOFEAR-AUD-017: impact drops a sub from 82 to 28 Hz and scales with strength', () => {
audio.start()
const m = mark()
audio.impact(0.5)
const sub = oscs(since(m))[0]
expect(sub.frequency.setValueAtTime.mock.calls[0][0]).toBe(82)
const sweep = sub.frequency.exponentialRampToValueAtTime.mock.calls[0]
expect(sweep[0]).toBe(28)
expect(sweep[1]).toBeCloseTo(0.92)
expect(sub.started[0]).toBeCloseTo(0.02)
expect(sub.stopped[0]).toBeCloseTo(1.82)
const body = gains(since(m))[0]
expect(body.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.45)
expect(hallSend(body)).toBe(0.5)
})
it('FE-NOFEAR-AUD-018: impact doubles the sub with a lowpassed noise burst', () => {
audio.start()
const m = mark()
audio.impact()
const burst = only(bufs(since(m)))
expect(burst.started[0]).toBeCloseTo(0.02)
expect(burst.stopped[0]).toBeCloseTo(0.82)
const lp = only(filters(since(m)))
expect(lp.type).toBe('lowpass')
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBe(900)
expect(lp.frequency.exponentialRampToValueAtTime.mock.calls[0][0]).toBe(120)
expect(burst.connections).toEqual([lp])
const noiseGain = gains(since(m)).find((g) => lp.connections.includes(g)) as FakeGain
expect(noiseGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.3)
expect(hallSend(noiseGain)).toBe(0.6)
})
})
describe('acts', () => {
it('FE-NOFEAR-AUD-019: fear lays a sub, a drifting fifth and a breathing rumble', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const list = since(m)
const sub = oscs(list).find((o) => o.type === 'sine' && o.frequency.value === 55)
expect(sub).toBeDefined()
const fifth = oscs(list).find((o) => o.type === 'triangle' && o.frequency.value === 82.4)
expect(fifth).toBeDefined()
// the detune LFO drives the fifth's detune param, not its output
const drift = gains(list).find((g) => g.connections.includes(fifth!.detune)) as FakeGain
expect(drift.gain.value).toBe(6)
// the rumble's gain breathes from a second LFO
const rumble = bufs(list).find((n) => n.loop) as FakeBufferSource
const rlp = filters(list).find((f) => rumble.connections.includes(f)) as FakeBiquadFilter
expect(rlp.type).toBe('lowpass')
expect(rlp.frequency.value).toBe(120)
const rumbleGain = gains(list).find((g) => rlp.connections.includes(g)) as FakeGain
const breathe = gains(list).find((g) => g.connections.includes(rumbleGain.gain)) as FakeGain
expect(breathe.gain.value).toBe(0.25)
// no grind semitone in the fear act
expect(oscs(list).some((o) => o.frequency.value === 58.27)).toBe(false)
})
it('FE-NOFEAR-AUD-020: fear opens the bed and the wind from silence', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const list = since(m)
const bed = gains(list)[0]
expect(bed.gain.setValueAtTime.mock.calls[0][0]).toBe(0.0001)
expect(bed.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.16, 3])
expect(hallSend(bed)).toBe(0.3)
const bp = filters(list).find((f) => f.type === 'bandpass') as FakeBiquadFilter
expect(bp.frequency.value).toBe(420)
expect(bp.Q.value).toBe(0.6)
})
it('FE-NOFEAR-AUD-021: the wind LFO modulates a series stage, never the release envelope', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const list = since(m)
const trem = gains(list).find((g) => g.gain.value === 0.75) as FakeGain
expect(trem).toBeDefined()
const lfoGain = gains(list).find((g) => g.connections.includes(trem.gain)) as FakeGain
expect(lfoGain.gain.value).toBe(0.4)
// the wind envelope itself must stay free of LFO input, otherwise the hard
// cut into the silence act could never mute it
const windEnv = gains(list).find((g) => trem.connections.includes(g)) as FakeGain
expect(gains(list).some((g) => g.connections.includes(windEnv.gain))).toBe(false)
})
it('FE-NOFEAR-AUD-022: dread adds the grinding semitone and a 14s riser', () => {
audio.start()
const m = mark()
audio.setAct('dread')
const list = since(m)
expect(oscs(list).some((o) => o.frequency.value === 58.27)).toBe(true)
const riser = only(oscs(list).filter((o) => o.type === 'sawtooth'))
expect(riser.frequency.setValueAtTime.mock.calls[0][0]).toBe(180)
expect(riser.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([820, 14])
const riserBp = filters(list).find(
(f) => f.type === 'bandpass' && f.Q.value === 8,
) as FakeBiquadFilter
expect(riserBp.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([1400, 14])
const noiseSweep = filters(list).find(
(f) => f.type === 'bandpass' && f.Q.value === 1.4,
) as FakeBiquadFilter
expect(noiseSweep.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([3200, 14])
})
it('FE-NOFEAR-AUD-023: setting the same act twice changes nothing', () => {
audio.start()
audio.setAct('fear')
const m = mark()
audio.setAct('fear')
expect(since(m)).toHaveLength(0)
})
it('FE-NOFEAR-AUD-024: silence pulls the fear act away fast and stops the beat', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const bed = gains(since(m))[0]
const bedOscs = oscs(since(m))
advance(400)
const afterCut = mark()
audio.setAct('silence')
// release 0.6 instead of the usual 1.6
expect(bed.gain.cancelScheduledValues).toHaveBeenCalledWith(0)
const target = bed.gain.setTargetAtTime.mock.calls[0]
expect(target[0]).toBe(0)
expect(target[1]).toBeCloseTo(0.9)
expect(target[2]).toBe(0.25)
expect(bedOscs[0].stopped[0]).toBeCloseTo(2.5)
advance(2000)
expect(thumpTimes(since(afterCut))).toHaveLength(0)
})
it('FE-NOFEAR-AUD-025: a normal act change uses the slow 1.6s release', () => {
audio.start()
const m = mark()
audio.setAct('fear')
const bedOscs = oscs(since(m))
audio.setAct('dread')
expect(bedOscs[0].stopped[0]).toBeCloseTo(3.1)
})
it('FE-NOFEAR-AUD-026: hope opens the progression without a sub root', () => {
audio.start()
const m = mark()
audio.setAct('hope')
const list = since(m)
const pads = oscs(list).filter((o) => o.type === 'triangle')
expect(pads).toHaveLength(10)
expect(oscs(list).some((o) => o.type === 'sine')).toBe(false)
const lp = only(filters(list))
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBeCloseTo(245)
expect(lp.frequency.exponentialRampToValueAtTime.mock.calls[0]).toEqual([700, 7])
const padGain = gains(list).find((g) => lp.connections.includes(g)) as FakeGain
expect(padGain.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.085, 3.2])
expect(hallSend(padGain)).toBe(0.55)
})
it('FE-NOFEAR-AUD-027: the pad is ten detuned voices on the opening D chord', () => {
audio.start()
const m = mark()
audio.setAct('hope')
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
expect(pads.map((o) => o.frequency.value)).toEqual([
73.42, 73.42, 110.0, 110.0, 146.83, 146.83, 185.0, 185.0, 293.66, 293.66,
])
expect(pads.map((o) => o.detune.value)).toEqual([-5, 5, -5, 5, -5, 5, -5, 5, -5, 5])
expect(pads.every((o) => o.started[0] === 0)).toBe(true)
})
it('FE-NOFEAR-AUD-028: anthem adds a dry sub root and opens the filter wide', () => {
audio.start()
const m = mark()
audio.setAct('anthem')
const list = since(m)
const lp = filters(list).find((f) => f.type === 'lowpass') as FakeBiquadFilter
expect(lp.frequency.setValueAtTime.mock.calls[0][0]).toBeCloseTo(840)
const sub = oscs(list).find((o) => o.frequency.value === 36.71) as FakeOscillator
expect(sub.type).toBe('sine')
const subGain = gains(list).find((g) => sub.connections.includes(g)) as FakeGain
expect(subGain.gain.exponentialRampToValueAtTime.mock.calls[0]).toEqual([0.16, 2.5])
// low end stays out of the hall, otherwise it turns to mud
expect(hallSend(subGain)).toBeUndefined()
expect(subGain.connections).toEqual([masterGain()])
})
it('FE-NOFEAR-AUD-029: anthem sprinkles pentatonic pings through a feedback delay', () => {
audio.start()
const m = mark()
audio.setAct('anthem')
const list = since(m)
const delay = created.find((n) => n.kind === 'delay') as FakeDelay
expect(delay.delayTime.value).toBe(0.38)
const feedback = gains(list).find((g) => delay.connections.includes(g)) as FakeGain
expect(feedback.gain.value).toBe(0.35)
expect(feedback.connections).toEqual([delay])
expect(hallSend(delay)).toBe(0.8)
const pings = oscs(list).filter((o) => o.type === 'sine' && o.frequency.value !== 36.71)
expect(pings).toHaveLength(14)
const scale = [880, 1108.7, 1318.5, 1479.98, 1760]
expect(pings.every((o) => scale.includes(o.frequency.value))).toBe(true)
// one ping every ~0.8s, each ringing 2.1s
expect(pings[0].started[0]).toBeGreaterThanOrEqual(0.8)
expect(pings[13].started[0]).toBeGreaterThanOrEqual(0.8 + 13 * 0.8)
expect(pings[0].stopped[0]).toBeCloseTo(pings[0].started[0] + 2.1)
})
it('FE-NOFEAR-AUD-030: end fades the master out on a long tail', () => {
audio.start()
const master = masterGain()
advance(1000)
audio.setAct('end')
const call = master.gain.setTargetAtTime.mock.calls[0]
expect(call[0]).toBe(0)
expect(call[1]).toBeCloseTo(3.5)
expect(call[2]).toBe(1.2)
})
it('FE-NOFEAR-AUD-031: acts before the first gesture are ignored', () => {
audio.setAct('anthem')
expect(created).toHaveLength(0)
})
})
describe('progression', () => {
it('FE-NOFEAR-AUD-032: steps the pad onto the next chord every 4.4s', () => {
audio.start()
const m = mark()
audio.setAct('anthem')
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
advance(4400)
const expected = [82.41, 82.41, 110.0, 110.0, 164.81, 164.81, 220.0, 220.0, 277.18, 277.18]
pads.forEach((o, i) => {
const call = o.frequency.setTargetAtTime.mock.calls[0]
expect(call[0]).toBe(expected[i])
expect(call[2]).toBe(0.55)
})
})
it('FE-NOFEAR-AUD-033: the sub root walks DEF#E and wraps around', () => {
audio.start()
const m = mark()
audio.setAct('anthem')
const sub = oscs(since(m)).find((o) => o.frequency.value === 36.71) as FakeOscillator
advance(4400 * 4)
const roots = sub.frequency.setTargetAtTime.mock.calls.map((call) => call[0])
expect(roots).toEqual([41.2, 46.25, 41.2, 36.71])
expect(sub.frequency.setTargetAtTime.mock.calls[0][2]).toBe(0.5)
})
it('FE-NOFEAR-AUD-034: hope has no sub, so only the pad glides', () => {
audio.start()
const m = mark()
audio.setAct('hope')
const opened = since(m)
const pads = oscs(opened).filter((o) => o.type === 'triangle')
expect(oscs(opened).some((o) => o.type === 'sine')).toBe(false)
advance(4400)
expect(pads[0].frequency.setTargetAtTime).toHaveBeenCalledTimes(1)
expect(pads[0].frequency.setTargetAtTime.mock.calls[0][0]).toBe(82.41)
})
it('FE-NOFEAR-AUD-035: leaving the act stops the chord clock', () => {
audio.start()
const m = mark()
audio.setAct('hope')
const pads = oscs(since(m)).filter((o) => o.type === 'triangle')
audio.setAct('end')
advance(4400 * 2)
expect(pads[0].frequency.setTargetAtTime).not.toHaveBeenCalled()
})
})
describe('heartbeat', () => {
it('FE-NOFEAR-AUD-036: schedules a beat plus its echo ahead of the clock', () => {
audio.start()
const m = mark()
advance(1000)
const times = thumpTimes(since(m))
expect(times).toHaveLength(4)
expect(times[0]).toBeCloseTo(0.2)
expect(times[1]).toBeCloseTo(0.39)
expect(times[2] - times[0]).toBeCloseTo(60 / 76)
expect(times[3] - times[2]).toBeCloseTo(0.19)
})
it('FE-NOFEAR-AUD-037: a beat is a sine body plus a highpassed click', () => {
audio.start()
const m = mark()
advance(100)
const list = since(m)
const body = oscs(list)[0]
expect(body.type).toBe('sine')
expect(body.frequency.exponentialRampToValueAtTime.mock.calls[0][0]).toBe(34)
expect(body.stopped[0]).toBeCloseTo(0.52)
const bodyGain = body.connections[0] as FakeGain
expect(bodyGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.55)
expect(hallSend(bodyGain)).toBe(0.1)
const click = bufs(list)[0]
const hp = filters(list)[0]
expect(hp.type).toBe('highpass')
expect(hp.frequency.value).toBe(1700)
expect(click.connections).toEqual([hp])
const clickGain = gains(list).find((g) => hp.connections.includes(g)) as FakeGain
expect(clickGain.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.165)
// the click is close, not in the hall
expect(clickGain.connections).toEqual([masterGain()])
})
it('FE-NOFEAR-AUD-038: dread doubles the tempo and hits harder', () => {
audio.start()
audio.setAct('dread')
const m = mark()
advance(1000)
const times = thumpTimes(since(m))
expect(times).toHaveLength(6)
expect(times[2] - times[0]).toBeCloseTo(60 / 116)
const first = oscs(since(m))[0].connections[0] as FakeGain
expect(first.gain.exponentialRampToValueAtTime.mock.calls[0][0]).toBeCloseTo(0.7)
})
it('FE-NOFEAR-AUD-039: clamps the beat clock instead of back-filling after the silence', () => {
audio.start()
audio.setAct('silence')
advance(3000)
const m = mark()
audio.setAct('hope')
advance(100)
const times = thumpTimes(since(m))
expect(times).toHaveLength(2)
expect(times[0]).toBeCloseTo(3.2)
expect(times[1]).toBeCloseTo(3.39)
})
})
describe('teardown', () => {
it('FE-NOFEAR-AUD-040: dispose mutes immediately and closes the context late', async () => {
audio.start()
const master = masterGain()
audio.setAct('fear')
const context = ctx()
const m = mark()
audio.dispose()
expect(master.gain.setTargetAtTime).toHaveBeenLastCalledWith(0, 0, 0.05)
expect(context.close).not.toHaveBeenCalled()
advance(300)
await Promise.resolve()
expect(context.close).toHaveBeenCalledTimes(1)
// the heartbeat interval is gone with it
advance(2000)
expect(thumpTimes(since(m))).toHaveLength(0)
})
it('FE-NOFEAR-AUD-041: dispose swallows a rejected close and survives a second call', async () => {
behavior.closeRejects = true
audio.start()
const context = ctx()
audio.dispose()
advance(300)
await Promise.resolve()
expect(context.close).toHaveBeenCalledTimes(1)
const m = mark()
expect(() => audio.dispose()).not.toThrow()
advance(300)
expect(context.close).toHaveBeenCalledTimes(1)
audio.swell()
audio.impact()
audio.setAct('anthem')
expect(since(m)).toHaveLength(0)
})
it('FE-NOFEAR-AUD-042: a voice that refuses to stop does not break the act change', () => {
audio.start()
const m = mark()
audio.setAct('anthem')
const voices = [...oscs(since(m)), ...bufs(since(m))]
for (const v of voices) v.throwOnStop = true
expect(() => audio.setAct('end')).not.toThrow()
expect(voices.every((v) => v.stop.mock.calls.length > 0)).toBe(true)
})
})
})
@@ -0,0 +1,609 @@
// FE-NOFEAR-SCN-001 to FE-NOFEAR-SCN-028
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from '../../../tests/helpers/msw/server'
import { NoFearScene, type SceneState } from './noFearScene'
const GEO_URL = '/api/addons/atlas/countries/geo'
interface DrawImageRecord { image: unknown; args: number[]; globalAlpha: number; composite: string }
interface FillRectRecord { args: number[]; fillStyle: string; globalAlpha: number; composite: string }
interface StrokeRecord { strokeStyle: string; lineWidth: number; globalAlpha: number }
interface ArcFillRecord { x: number; y: number; r: number; fillStyle: string; globalAlpha: number; composite: string }
interface FakeCtx {
fillStyle: string
strokeStyle: string
lineWidth: number
globalAlpha: number
globalCompositeOperation: string
clearRect: ReturnType<typeof vi.fn>
save: ReturnType<typeof vi.fn>
restore: ReturnType<typeof vi.fn>
beginPath: ReturnType<typeof vi.fn>
closePath: ReturnType<typeof vi.fn>
moveTo: ReturnType<typeof vi.fn>
lineTo: ReturnType<typeof vi.fn>
arc: ReturnType<typeof vi.fn>
fill: ReturnType<typeof vi.fn>
stroke: ReturnType<typeof vi.fn>
drawImage: ReturnType<typeof vi.fn>
fillRect: ReturnType<typeof vi.fn>
createRadialGradient: ReturnType<typeof vi.fn>
getImageData: ReturnType<typeof vi.fn>
images: DrawImageRecord[]
rects: FillRectRecord[]
strokes: StrokeRecord[]
arcFills: ArcFillRecord[]
rasters: number[][]
points: number[][]
}
// jsdom ships no canvas backend: every context is a recorder, getImageData
// replays `alphaAt`, and Path2D is a call log so the baked border geometry
// stays observable.
let alphaAt: (x: number, y: number, w: number) => number = () => 0
let contextAvailable = true
let contexts: FakeCtx[] = []
let path2dCount = 0
let pathMoveTo = 0
let pathLineTo = 0
let pathClose = 0
let rafCount = 0
let onFrame: ((n: number) => void) | null = null
const originalGetContext = HTMLCanvasElement.prototype.getContext
function makeCtx(): FakeCtx {
let lastArc: { x: number; y: number; r: number } | null = null
const ctx: FakeCtx = {
fillStyle: '',
strokeStyle: '',
lineWidth: 1,
globalAlpha: 1,
globalCompositeOperation: 'source-over',
clearRect: vi.fn(),
save: vi.fn(),
restore: vi.fn(),
beginPath: vi.fn(),
closePath: vi.fn(),
moveTo: vi.fn((x: number, y: number) => { ctx.points.push([x, y]) }),
lineTo: vi.fn((x: number, y: number) => { ctx.points.push([x, y]) }),
arc: vi.fn((x: number, y: number, r: number) => { lastArc = { x, y, r } }),
fill: vi.fn(() => {
if (!lastArc) return
ctx.arcFills.push({
x: lastArc.x, y: lastArc.y, r: lastArc.r,
fillStyle: ctx.fillStyle, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation,
})
}),
stroke: vi.fn(() => {
ctx.strokes.push({ strokeStyle: ctx.strokeStyle, lineWidth: ctx.lineWidth, globalAlpha: ctx.globalAlpha })
}),
drawImage: vi.fn((image: unknown, ...args: number[]) => {
ctx.images.push({ image, args, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation })
}),
fillRect: vi.fn((...args: number[]) => {
ctx.rects.push({ args, fillStyle: ctx.fillStyle, globalAlpha: ctx.globalAlpha, composite: ctx.globalCompositeOperation })
}),
createRadialGradient: vi.fn(() => ({ addColorStop: vi.fn() })),
getImageData: vi.fn((_x: number, _y: number, w: number, h: number) => {
ctx.rasters.push([w, h])
const data = new Uint8ClampedArray(w * h * 4)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) data[(y * w + x) * 4 + 3] = alphaAt(x, y, w)
}
return { data }
}),
images: [],
rects: [],
strokes: [],
arcFills: [],
rasters: [],
points: [],
}
return ctx
}
class FakePath2D {
moveTo(): void { pathMoveTo++ }
lineTo(): void { pathLineTo++ }
closePath(): void { pathClose++ }
constructor() { path2dCount++ }
}
/** mulberry32 — a fixed seed keeps arcs, dots and sparks identical across runs. */
function seedRandom(seed: number): void {
let a = seed >>> 0
vi.spyOn(Math, 'random').mockImplementation(() => {
a = (a + 0x6d2b79f5) >>> 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
})
}
const asCtx = (c: FakeCtx) => c as unknown as CanvasRenderingContext2D
const state = (over: Partial<SceneState> = {}): SceneState => ({
land: 0, cityLife: 0, cityDeath: 0, borderHeat: 0, borderBurst: 0,
web: 0, warmth: 0, personalGlow: 0, particles: 0, opacity: 1, ...over,
})
type Ring = [number, number][]
const ringA: Ring = [[-10, 50], [10, 50], [10, 40], [-10, 40], [-10, 50]]
const ringB: Ring = [[100, -20], [120, -20], [120, -30], [100, -30], [100, -20]]
const sliver: Ring = [[0, 0], [1, 1]]
const GEO = {
features: [
{ geometry: { type: 'Polygon', coordinates: [ringA] } },
{ geometry: { type: 'MultiPolygon', coordinates: [[ringB], [sliver]] } },
{},
{ geometry: { type: 'Point', coordinates: [0, 0] } },
],
}
function serveGeo(body: unknown): void {
server.use(http.get(GEO_URL, () => HttpResponse.json(body)))
}
/** The land-dot raster is the only 168-wide getImageData in this module. */
const isDotRaster = (r: number[]) => r[0] === 168 && r[1] === 84
const dotRasters = () => contexts.flatMap(c => c.rasters).filter(isDotRaster)
/** The web layer is the only offscreen context stroked in the arc's amber. */
const webContext = () => contexts.find(c => c.strokes.some(s => s.strokeStyle.startsWith('rgba(255, 176, 90')))
async function loadedScene(width = 800, height = 600): Promise<NoFearScene> {
serveGeo(GEO)
const scene = new NoFearScene()
await scene.load(width, height)
return scene
}
beforeEach(() => {
alphaAt = (x, y, w) => (w === 168 && x < 6 && y < 2 ? 255 : 0)
contextAvailable = true
contexts = []
path2dCount = 0
pathMoveTo = 0
pathLineTo = 0
pathClose = 0
rafCount = 0
onFrame = null
seedRandom(12345)
HTMLCanvasElement.prototype.getContext = vi.fn(() => {
if (!contextAvailable) return null
const c = makeCtx()
contexts.push(c)
return c
}) as unknown as HTMLCanvasElement['getContext']
vi.stubGlobal('Path2D', FakePath2D)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
rafCount += 1
onFrame?.(rafCount)
cb(0)
return rafCount
})
})
afterEach(() => {
HTMLCanvasElement.prototype.getContext = originalGetContext
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
describe('NoFearScene.load', () => {
it('FE-NOFEAR-SCN-001: bakes border, dot and web layers from the Atlas bundle', async () => {
const scene = await loadedScene()
expect(dotRasters()).toHaveLength(1)
expect(path2dCount).toBe(1)
// two surviving rings of five points each
expect(pathMoveTo).toBe(2)
expect(pathLineTo).toBe(8)
expect(pathClose).toBe(2)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
expect(ctx.images.length).toBeGreaterThan(0)
})
it('FE-NOFEAR-SCN-002: decimates rings against the global vertex budget', async () => {
const dense: Ring = Array.from({ length: 14001 }, (_, i) => [i % 360 - 180, (i % 120) - 50])
serveGeo({ features: [{ geometry: { type: 'Polygon', coordinates: [dense] } }] })
await new NoFearScene().load(800, 600)
// 14001 vertices → step 2 → 7001 kept, one moveTo + 7000 lineTo
expect(pathMoveTo).toBe(1)
expect(pathLineTo).toBe(7000)
})
it('FE-NOFEAR-SCN-003: drops rings too short to survive decimation', async () => {
serveGeo({ features: [{ geometry: { type: 'Polygon', coordinates: [sliver] } }] })
const scene = new NoFearScene()
await scene.load(800, 600)
// border layer still baked, but with an empty path and no sparks
expect(path2dCount).toBe(1)
expect(pathMoveTo).toBe(0)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ borderBurst: 0.5 }), 0)
expect(ctx.rects).toHaveLength(0)
})
it('FE-NOFEAR-SCN-004: ignores a bundle without usable geometry', async () => {
serveGeo({ features: [{}, { geometry: { type: 'Point', coordinates: [0, 0] } }] })
await new NoFearScene().load(800, 600)
expect(path2dCount).toBe(0)
expect(dotRasters()).toHaveLength(0)
})
it('FE-NOFEAR-SCN-005: ignores a bundle without a features array', async () => {
serveGeo({})
await new NoFearScene().load(800, 600)
expect(path2dCount).toBe(0)
})
it('FE-NOFEAR-SCN-006: ignores a non-ok response', async () => {
server.use(http.get(GEO_URL, () => HttpResponse.json({ error: 'off' }, { status: 404 })))
await new NoFearScene().load(800, 600)
expect(path2dCount).toBe(0)
expect(dotRasters()).toHaveLength(0)
})
it('FE-NOFEAR-SCN-007: swallows a network failure and keeps the scene usable', async () => {
server.use(http.get(GEO_URL, () => HttpResponse.error()))
const scene = new NoFearScene()
await expect(scene.load(800, 600)).resolves.toBeUndefined()
expect(path2dCount).toBe(0)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
expect(ctx.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-008: aborts before the geometry pass', async () => {
const controller = new AbortController()
// The abort has to land between the response and the first frame yield,
// which only a hand-rolled fetch can time precisely.
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
json: async () => { controller.abort(); return GEO },
})))
const scene = new NoFearScene()
await scene.load(800, 600, controller.signal)
expect(dotRasters()).toHaveLength(0)
expect(path2dCount).toBe(0)
// no rings were stored, so a resize cannot rebake either
scene.layout(1000, 700)
expect(path2dCount).toBe(0)
})
it('FE-NOFEAR-SCN-009: aborts after the rings are decimated', async () => {
const controller = new AbortController()
onFrame = n => { if (n === 1) controller.abort() }
serveGeo(GEO)
const scene = new NoFearScene()
await scene.load(800, 600, controller.signal)
expect(dotRasters()).toHaveLength(0)
expect(path2dCount).toBe(0)
// the decimated rings survived, so a resize bakes them
onFrame = null
scene.layout(1000, 700)
expect(path2dCount).toBe(1)
})
it('FE-NOFEAR-SCN-010: aborts after the land dots are sampled', async () => {
const controller = new AbortController()
onFrame = n => { if (n === 2) controller.abort() }
serveGeo(GEO)
const scene = new NoFearScene()
await scene.load(800, 600, controller.signal)
expect(dotRasters()).toHaveLength(1)
expect(path2dCount).toBe(0)
})
it('FE-NOFEAR-SCN-011: survives a browser without a 2d context', async () => {
contextAvailable = false
serveGeo(GEO)
const scene = new NoFearScene()
await expect(scene.load(800, 600)).resolves.toBeUndefined()
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ land: 1, borderHeat: 1 }), 0)
// no dot layers and no border layer were baked
expect(ctx.images).toHaveLength(0)
})
})
describe('NoFearScene.layout', () => {
it('FE-NOFEAR-SCN-012: rebakes the layers on resize but not on a no-op layout', async () => {
const scene = await loadedScene(800, 600)
expect(path2dCount).toBe(1)
scene.layout(1000, 700)
expect(path2dCount).toBe(2)
scene.layout(1000, 700)
expect(path2dCount).toBe(2)
})
it('FE-NOFEAR-SCN-031: draws in raw lon/lat until the first layout', () => {
const raw = makeCtx()
new NoFearScene().draw(asCtx(raw), state({ web: 0.5 }), 0)
expect(raw.points.length).toBeGreaterThan(0)
expect(raw.points.every(([x, y]) => x >= -180 && x <= 180 && y >= -90 && y <= 90)).toBe(true)
const scene = new NoFearScene()
scene.layout(800, 600)
const projected = makeCtx()
scene.draw(asCtx(projected), state({ web: 0.5 }), 0)
expect(projected.points.some(([x]) => x < -180 || x > 180)).toBe(true)
})
it('FE-NOFEAR-SCN-013: projects longitude and latitude into a cover-fitted equirectangular frame', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ cityLife: 1 }), 0)
// scale = max(800/360, 600/136) ≈ 4.41; Berlin (13.4E) sits right of centre
const scale = Math.max(800 / 360, 600 / 136)
const ox = (800 - 360 * scale) / 2
const oy = (600 - 136 * scale) / 2
const berlin = ctx.images[0]
expect(berlin.args[0]).toBeCloseTo(ox + (13.4 + 180) * scale - 8, 6)
expect(berlin.args[1]).toBeCloseTo(oy + (78 - 52.52) * scale - 8, 6)
})
})
describe('NoFearScene.draw', () => {
it('FE-NOFEAR-SCN-014: clears the canvas and stops at zero opacity', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ land: 1, opacity: 0 }), 0)
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 800, 600)
expect(ctx.save).not.toHaveBeenCalled()
expect(ctx.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-015: blits one cold dot layer per twinkle group', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ land: 1 }), 0)
expect(ctx.images).toHaveLength(3)
// twinkle at t=0, g=0 sits at its 0.72 floor
expect(ctx.images[0].globalAlpha).toBeCloseTo(0.6 * 0.72, 6)
expect(ctx.images[1].globalAlpha).toBeCloseTo(0.6 * (0.72 + 0.28 * Math.sin(2.1)), 6)
expect(ctx.globalAlpha).toBe(1)
expect(ctx.restore).toHaveBeenCalledTimes(1)
})
it('FE-NOFEAR-SCN-016: crossfades the cold dot layers into the warm ones', async () => {
const scene = await loadedScene(800, 600)
const cold = makeCtx()
scene.draw(asCtx(cold), state({ land: 1, warmth: 0 }), 0)
const warm = makeCtx()
scene.draw(asCtx(warm), state({ land: 1, warmth: 1 }), 0)
const both = makeCtx()
scene.draw(asCtx(both), state({ land: 1, warmth: 0.5 }), 0)
expect(warm.images).toHaveLength(3)
expect(both.images).toHaveLength(6)
const coldLayers = cold.images.map(i => i.image)
expect(warm.images.some(i => coldLayers.includes(i.image))).toBe(false)
expect(both.images[0].globalAlpha).toBeCloseTo(0.6 * 0.72 * 0.5, 6)
})
it('FE-NOFEAR-SCN-017: lights every city in the opening act', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ cityLife: 1 }), 0)
expect(ctx.images).toHaveLength(51)
expect(ctx.images[0].args.slice(2)).toEqual([16, 16])
expect(ctx.images[0].composite).toBe('lighter')
expect(ctx.images[0].globalAlpha).toBeCloseTo(0.5, 6)
expect(ctx.globalCompositeOperation).toBe('source-over')
})
it('FE-NOFEAR-SCN-018: kills the city lights one by one during the fear act', async () => {
const scene = await loadedScene(800, 600)
const half = makeCtx()
scene.draw(asCtx(half), state({ cityLife: 1, cityDeath: 0.5 }), 0)
expect(half.images.length).toBeGreaterThan(0)
expect(half.images.length).toBeLessThan(51)
const dead = makeCtx()
scene.draw(asCtx(dead), state({ cityLife: 1, cityDeath: 1 }), 0)
expect(dead.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-019: hands the cities over to the web layer once the web grows', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ cityLife: 1, web: 0.01 }), 0)
// no 16x16 opening sprites — the web block owns the cities from here
expect(ctx.images.filter(i => i.args[2] === 16)).toHaveLength(0)
})
it('FE-NOFEAR-SCN-020: pulses the baked border layer', async () => {
const scene = await loadedScene(800, 600)
const hot = makeCtx()
scene.draw(asCtx(hot), state({ borderHeat: 1 }), 0)
expect(hot.images).toHaveLength(1)
expect(hot.images[0].args).toEqual([0, 0])
expect(hot.images[0].globalAlpha).toBeCloseTo(0.72, 6)
const halfLit = makeCtx()
scene.draw(asCtx(halfLit), state({ borderHeat: 1, borderBurst: 0.5 }), 0)
expect(halfLit.images[0].globalAlpha).toBeCloseTo(0.5 * 0.72, 6)
const gone = makeCtx()
scene.draw(asCtx(gone), state({ borderHeat: 1, borderBurst: 1 }), 0)
expect(gone.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-021: shatters the borders into drifting sparks', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ borderBurst: 0.5 }), 0)
expect(ctx.rects.length).toBeGreaterThan(0)
expect(ctx.rects.length).toBeLessThanOrEqual(10)
for (const r of ctx.rects) {
expect(r.args.slice(2)).toEqual([1.6, 1.6])
expect(['rgb(255, 150, 95)', 'rgb(255, 205, 130)']).toContain(r.fillStyle)
expect(r.composite).toBe('lighter')
}
})
it('FE-NOFEAR-SCN-022: drops sparks once they have burned out', async () => {
const scene = await loadedScene(800, 600)
const spent = makeCtx()
scene.draw(asCtx(spent), state({ borderBurst: 0.99 }), 0)
expect(spent.rects).toHaveLength(0)
const over = makeCtx()
scene.draw(asCtx(over), state({ borderBurst: 1 }), 0)
expect(over.rects).toHaveLength(0)
})
it('FE-NOFEAR-SCN-023: bakes finished arcs once and strokes only the growing ones', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
const web = webContext()
expect(web).toBeDefined()
const bakedStrokes = web!.strokes.length
expect(bakedStrokes).toBeGreaterThan(0)
// two passes (glow + core) per baked arc
expect(bakedStrokes % 2).toBe(0)
expect(web!.strokes[0].lineWidth).toBe(4.2)
expect(web!.strokes[1].lineWidth).toBe(1.2)
// arcs still in flight are stroked on the live context instead
expect(ctx.strokes.length).toBeGreaterThan(0)
expect(ctx.images.some(i => i.args.length === 2)).toBe(true)
// a second frame at the same progress must not re-bake anything
const again = makeCtx()
scene.draw(asCtx(again), state({ web: 0.5 }), 0)
expect(web!.strokes).toHaveLength(bakedStrokes)
})
it('FE-NOFEAR-SCN-024: strokes a full arc with a fractional tip while it grows', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
const growing = ctx.strokes.length / 2
expect(growing).toBeGreaterThan(0)
// per growing arc: one moveTo, floor(48*local) segments plus the tip
expect(ctx.moveTo).toHaveBeenCalledTimes(growing)
expect(ctx.lineTo.mock.calls.length).toBeGreaterThan(growing)
expect(ctx.strokes[0].strokeStyle).toMatch(/^rgba\(255, 176, 90, /)
expect(ctx.strokes[1].strokeStyle).toMatch(/^rgba\(255, 202, 122, /)
})
it('FE-NOFEAR-SCN-025: a fully grown web lives entirely in the baked layer', async () => {
const scene = await loadedScene(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ web: 1 }), 0)
expect(ctx.strokes).toHaveLength(0)
// one web-layer blit plus a halo and a core per city
expect(ctx.images).toHaveLength(1 + 51 * 2)
expect(ctx.images[0].args).toEqual([0, 0])
expect(ctx.images[2].args.slice(2)).toEqual([6.4, 6.4])
})
it('FE-NOFEAR-SCN-026: grows the web without any Atlas geometry', () => {
const scene = new NoFearScene()
scene.layout(800, 600)
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ web: 0.5 }), 0)
expect(ctx.strokes.length).toBeGreaterThan(0)
expect(ctx.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-027: ignites the user places in order with an overshoot pulse', async () => {
const scene = await loadedScene(800, 600)
scene.setPersonalPlaces([
{ lat: 52.5, lng: 13.4 }, { lat: 48.8, lng: 2.3 },
{ lat: -33.9, lng: 151.2 }, { lat: 40.7, lng: -74 },
])
const full = makeCtx()
scene.draw(asCtx(full), state({ personalGlow: 1 }), 0)
expect(full.images).toHaveLength(8)
expect(full.images[1].args.slice(2)).toEqual([5.2, 5.2])
// fully lit → no overshoot left on the halo
expect(full.images[0].args[2]).toBeCloseTo(14, 6)
const early = makeCtx()
scene.draw(asCtx(early), state({ personalGlow: 0.1 }), 0)
expect(early.images).toHaveLength(2)
expect(early.images[0].args[2]).toBeGreaterThan(14)
})
it('FE-NOFEAR-SCN-028: caps the personal places at 400', async () => {
const scene = await loadedScene(800, 600)
scene.setPersonalPlaces(Array.from({ length: 450 }, (_, i) => ({ lat: (i % 80) - 40, lng: (i % 300) - 150 })))
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ personalGlow: 1 }), 0)
expect(ctx.images).toHaveLength(800)
})
it('FE-NOFEAR-SCN-029: skips the personal glow before the sprites are baked', () => {
const scene = new NoFearScene()
scene.layout(800, 600)
scene.setPersonalPlaces([{ lat: 10, lng: 10 }])
const ctx = makeCtx()
scene.draw(asCtx(ctx), state({ personalGlow: 1 }), 0)
expect(ctx.images).toHaveLength(0)
})
it('FE-NOFEAR-SCN-030: raises the anthem particles and wraps them around the viewport', () => {
const scene = new NoFearScene()
scene.layout(800, 600)
const start = makeCtx()
scene.draw(asCtx(start), state({ particles: 1 }), 0)
expect(start.arcFills).toHaveLength(90)
for (const f of start.arcFills) {
expect(f.fillStyle).toBe('rgb(255, 210, 150)')
expect(f.composite).toBe('lighter')
expect(f.y).toBeGreaterThanOrEqual(0)
expect(f.y).toBeLessThanOrEqual(1.15 * 600)
}
const later = makeCtx()
scene.draw(asCtx(later), state({ particles: 1 }), 12)
expect(later.arcFills.map(f => f.y)).not.toEqual(start.arcFills.map(f => f.y))
expect(later.arcFills.every(f => f.y >= 0 && f.y <= 1.15 * 600)).toBe(true)
})
})
@@ -144,7 +144,7 @@ export class NoFearScene {
if (r.length < step * 4) continue // rings too small to survive decimation are visual noise
const thin: Ring = []
for (let i = 0; i < r.length; i += step) thin.push(r[i])
if (thin.length >= 4) rings.push(thin)
rings.push(thin)
}
this.rings = rings
if (signal?.aborted) return
@@ -0,0 +1,175 @@
// FE-JRN-INVITE-001 to FE-JRN-INVITE-011
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, act } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import ContributorInviteDialog from './ContributorInviteDialog'
type ToastKind = 'success' | 'error' | 'warning' | 'info'
const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0)
const users = [
{ id: 1, username: 'maurice', email: 'maurice@example.com' },
{ id: 2, username: 'julien', email: 'julien@trek.dev' },
{ id: 3, username: 'anna', email: 'anna@example.com' },
]
function mountDialog(props: Partial<React.ComponentProps<typeof ContributorInviteDialog>> = {}) {
const onClose = vi.fn()
const onInvited = vi.fn()
render(
<ContributorInviteDialog
journeyId={4}
existingUserIds={[]}
onClose={onClose}
onInvited={onInvited}
{...props}
/>,
)
return { onClose, onInvited }
}
beforeEach(() => {
toastSpy.mockClear()
window.__addToast = toastSpy
server.use(http.get('/api/auth/users', () => HttpResponse.json({ users })))
})
afterEach(() => {
delete window.__addToast
})
describe('ContributorInviteDialog', () => {
it('FE-JRN-INVITE-001: lists every selectable user returned by the API', async () => {
mountDialog()
expect(await screen.findByText('maurice')).toBeInTheDocument()
expect(screen.getByText('julien@trek.dev')).toBeInTheDocument()
expect(screen.getByText('anna')).toBeInTheDocument()
})
it('FE-JRN-INVITE-002: hides users that already contribute to the journey', async () => {
mountDialog({ existingUserIds: [1, 3] })
expect(await screen.findByText('julien')).toBeInTheDocument()
expect(screen.queryByText('maurice')).not.toBeInTheDocument()
expect(screen.queryByText('anna')).not.toBeInTheDocument()
})
it('FE-JRN-INVITE-003: filters the list by username', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('maurice')
await user.type(screen.getByPlaceholderText('Username or email...'), 'jul')
expect(screen.getByText('julien')).toBeInTheDocument()
expect(screen.queryByText('maurice')).not.toBeInTheDocument()
})
it('FE-JRN-INVITE-004: filters the list by email address', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('maurice')
await user.type(screen.getByPlaceholderText('Username or email...'), 'trek.dev')
expect(screen.getByText('julien')).toBeInTheDocument()
expect(screen.queryByText('anna')).not.toBeInTheDocument()
})
it('FE-JRN-INVITE-005: shows the empty hint when nothing matches the search', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('maurice')
await user.type(screen.getByPlaceholderText('Username or email...'), 'nobody')
expect(screen.getByText('No users found')).toBeInTheDocument()
})
it('FE-JRN-INVITE-006: keeps the invite button disabled until a user is selected', async () => {
const user = userEvent.setup()
mountDialog()
const row = await screen.findByText('julien')
const inviteBtn = screen.getByRole('button', { name: 'Invite' })
expect(inviteBtn).toBeDisabled()
await user.click(row)
expect(inviteBtn).toBeEnabled()
})
it('FE-JRN-INVITE-007: defaults the role to viewer and switches to editor on click', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('maurice')
const viewerBtn = screen.getByRole('button', { name: 'Viewer' })
const editorBtn = screen.getByRole('button', { name: 'Editor' })
expect(viewerBtn.className).toContain('bg-zinc-900')
expect(editorBtn.className).not.toContain('bg-zinc-900')
await user.click(editorBtn)
expect(editorBtn.className).toContain('bg-zinc-900')
expect(viewerBtn.className).not.toContain('bg-zinc-900')
})
it('FE-JRN-INVITE-008: posts the selected user and role, then reports success', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post('/api/journeys/4/contributors', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { onInvited } = mountDialog()
await user.click(await screen.findByText('julien'))
await user.click(screen.getByRole('button', { name: 'Editor' }))
await user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => expect(onInvited).toHaveBeenCalledTimes(1))
expect(bodies[0]).toEqual({ user_id: 2, role: 'editor' })
expect(toastSpy).toHaveBeenCalledWith('Contributor added', 'success', undefined)
})
it('FE-JRN-INVITE-009: reports a failed invite without notifying the parent', async () => {
server.use(http.post('/api/journeys/4/contributors', () => new HttpResponse(null, { status: 403 })))
const user = userEvent.setup()
const { onInvited } = mountDialog()
await user.click(await screen.findByText('anna'))
await user.click(screen.getByRole('button', { name: 'Invite' }))
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith('Failed to add contributor', 'error', undefined)
})
expect(onInvited).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Invite' })).toBeEnabled()
})
it('FE-JRN-INVITE-010: closes on both the header and the footer cancel button', async () => {
const user = userEvent.setup()
const { onClose } = mountDialog()
await screen.findByText('maurice')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
// The header close button carries only an icon, so it is addressed by position.
const headerClose = screen.getByRole('heading', { name: 'Invite Contributor' })
.parentElement!.querySelector('button')!
act(() => { headerClose.click() })
expect(onClose).toHaveBeenCalledTimes(2)
})
it('FE-JRN-INVITE-011: renders the empty hint when the user list cannot be fetched', async () => {
server.use(http.get('/api/auth/users', () => new HttpResponse(null, { status: 500 })))
mountDialog()
expect(await screen.findByText('No users found')).toBeInTheDocument()
})
})
@@ -1,4 +1,4 @@
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-005
// FE-COMP-JOURNALBODY-001 to FE-COMP-JOURNALBODY-013
import { describe, it, expect } from 'vitest';
import { render, screen } from '../../../tests/helpers/render';
@@ -36,4 +36,66 @@ describe('JournalBody', () => {
const { container } = render(<JournalBody text="" />);
expect(container.querySelector('.journal-body')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-006: renders block quotes with the journal accent border', () => {
const { container } = render(<JournalBody text="> Remember the light" />);
const quote = container.querySelector('blockquote');
expect(quote).toBeInTheDocument();
expect(quote!.textContent).toContain('Remember the light');
expect(quote!.getAttribute('style')).toContain('var(--journal-accent)');
});
it('FE-COMP-JOURNALBODY-007: renders bullet and numbered lists', () => {
const { container } = render(<JournalBody text={'- one\n- two\n\n1. first\n2. second'} />);
expect(container.querySelectorAll('ul li')).toHaveLength(2);
expect(container.querySelectorAll('ol li')).toHaveLength(2);
expect(screen.getByText('second')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-008: renders emphasis and horizontal rules', () => {
const { container } = render(<JournalBody text={'Some *stress* here\n\n---\n\nAfter'} />);
expect(container.querySelector('em')!.textContent).toBe('stress');
expect(container.querySelector('hr')).toBeInTheDocument();
});
it('FE-COMP-JOURNALBODY-009: renders inline code without a pre wrapper', () => {
const { container } = render(<JournalBody text="Run `npm test` now" />);
const code = container.querySelector('code');
expect(code!.textContent).toBe('npm test');
expect(container.querySelector('pre')).not.toBeInTheDocument();
expect(code!.getAttribute('style')).toContain('rgba(0, 0, 0, 0.06)');
});
it('FE-COMP-JOURNALBODY-010: renders fenced code blocks inside a single pre element', () => {
const { container } = render(<JournalBody text={'```js\nconst a = 1\n```'} />);
const pres = container.querySelectorAll('pre');
expect(pres).toHaveLength(1);
expect(pres[0].querySelector('code')!.textContent).toContain('const a = 1');
});
it('FE-COMP-JOURNALBODY-011: renders h1 and h3 as plain paragraphs', () => {
const { container } = render(<JournalBody text={'# Big\n\n### Small'} />);
expect(container.querySelector('h1')).not.toBeInTheDocument();
expect(container.querySelector('h3')).not.toBeInTheDocument();
expect(screen.getByText('Big').tagName).toBe('P');
expect(screen.getByText('Small').tagName).toBe('P');
});
it('FE-COMP-JOURNALBODY-012: keeps a setext underline as text instead of a heading', () => {
// The pre-pass inserts a blank line so "====" stays its own block.
const { container } = render(<JournalBody text={'Day One\n===='} />);
expect(screen.getByText('Day One')).toBeInTheDocument();
expect(screen.getByText('====')).toBeInTheDocument();
expect(container.querySelectorAll('p')).toHaveLength(2);
});
it('FE-COMP-JOURNALBODY-013: darkens code backgrounds in dark mode', () => {
const { container } = render(<JournalBody text={'`x`\n\n```js\ny\n```'} dark />);
const [inline] = Array.from(container.querySelectorAll('code'));
expect(inline.getAttribute('style')).toContain('rgba(255, 255, 255, 0.08)');
// the block styling sits on react-markdown's own <pre>, there is no second one
const pres = container.querySelectorAll('pre');
expect(pres).toHaveLength(1);
expect(pres[0].getAttribute('style')).toContain('rgba(255, 255, 255, 0.05)');
});
});
+10 -11
View File
@@ -41,19 +41,18 @@ export default function JournalBody({ text, dark }: Props) {
strong: ({ children }) => <strong style={{ fontWeight: 600 }}>{children}</strong>,
em: ({ children }) => <em>{children}</em>,
hr: () => <hr style={{ border: 'none', borderTop: '1px solid var(--journal-border)', margin: '20px 0' }} />,
// react-markdown already wraps fenced code in a <pre>, so the block
// styling belongs here — the code renderer only handles inline code.
pre: ({ children }) => (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
}}>{children}</pre>
),
code: ({ children, className }) => {
const isBlock = className?.includes('language-')
if (isBlock) {
return (
<pre style={{
background: dark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)',
borderRadius: 8, padding: 14, overflowX: 'auto',
fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontFamily: 'monospace', margin: '12px 0',
}}>
<code>{children}</code>
</pre>
)
}
if (isBlock) return <code>{children}</code>
return (
<code style={{
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)',
@@ -0,0 +1,148 @@
// FE-JRN-ADDTRIP-001 to FE-JRN-ADDTRIP-009
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, act } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { AddTripDialog } from './JourneyDetailPageAddTripDialog'
type ToastKind = 'success' | 'error' | 'warning' | 'info'
const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0)
const trips = [
{ id: 11, title: 'Italy Roadtrip', destination: 'Rome', start_date: '2026-03-14', end_date: '2026-03-20' },
{ id: 12, title: 'Winter Break', destination: 'Tromso' },
{ id: 13, title: 'City Hop' },
]
function mountDialog(props: Partial<React.ComponentProps<typeof AddTripDialog>> = {}) {
const onClose = vi.fn()
const onAdded = vi.fn()
render(
<AddTripDialog journeyId={4} existingTripIds={[]} onClose={onClose} onAdded={onAdded} {...props} />,
)
return { onClose, onAdded }
}
beforeEach(() => {
toastSpy.mockClear()
window.__addToast = toastSpy
server.use(http.get('/api/journeys/available-trips', () => HttpResponse.json({ trips })))
})
afterEach(() => {
delete window.__addToast
})
describe('AddTripDialog', () => {
it('FE-JRN-ADDTRIP-001: lists the trips available for linking', async () => {
mountDialog()
expect(await screen.findByText('Italy Roadtrip')).toBeInTheDocument()
expect(screen.getByText('Winter Break')).toBeInTheDocument()
expect(screen.getByText('City Hop')).toBeInTheDocument()
})
it('FE-JRN-ADDTRIP-002: renders destination and start date as the trip subtitle', async () => {
mountDialog()
expect(await screen.findByText('Rome · 2026-03-14')).toBeInTheDocument()
// Trips without a start date only show the destination.
expect(screen.getByText('Tromso')).toBeInTheDocument()
})
it('FE-JRN-ADDTRIP-003: hides trips that are already linked', async () => {
mountDialog({ existingTripIds: [11, 13] })
expect(await screen.findByText('Winter Break')).toBeInTheDocument()
expect(screen.queryByText('Italy Roadtrip')).not.toBeInTheDocument()
expect(screen.queryByText('City Hop')).not.toBeInTheDocument()
})
it('FE-JRN-ADDTRIP-004: filters by title and by destination', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('Italy Roadtrip')
const search = screen.getByPlaceholderText('Trip name or destination...')
await user.type(search, 'city')
expect(screen.getByText('City Hop')).toBeInTheDocument()
expect(screen.queryByText('Winter Break')).not.toBeInTheDocument()
await user.clear(search)
await user.type(search, 'tromso')
expect(screen.getByText('Winter Break')).toBeInTheDocument()
expect(screen.queryByText('City Hop')).not.toBeInTheDocument()
})
it('FE-JRN-ADDTRIP-005: shows the empty hint when no trip matches', async () => {
const user = userEvent.setup()
mountDialog()
await screen.findByText('Italy Roadtrip')
await user.type(screen.getByPlaceholderText('Trip name or destination...'), 'zzz')
expect(screen.getByText('No trips available')).toBeInTheDocument()
})
it('FE-JRN-ADDTRIP-006: links the picked trip and notifies the parent', async () => {
const bodies: Record<string, unknown>[] = []
server.use(http.post('/api/journeys/4/trips', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { onAdded } = mountDialog()
await screen.findByText('Italy Roadtrip')
await user.click(screen.getAllByRole('button', { name: 'Link' })[0])
await waitFor(() => expect(onAdded).toHaveBeenCalledTimes(1))
expect(bodies[0]).toEqual({ trip_id: 11 })
expect(toastSpy).toHaveBeenCalledWith('Trip linked', 'success', undefined)
})
it('FE-JRN-ADDTRIP-007: shows a busy state on the row while the link request runs', async () => {
server.use(http.post('/api/journeys/4/trips', async () => {
await delay(40)
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { onAdded } = mountDialog()
await screen.findByText('Italy Roadtrip')
await user.click(screen.getAllByRole('button', { name: 'Link' })[0])
expect(screen.getByRole('button', { name: '...' })).toBeDisabled()
await waitFor(() => expect(onAdded).toHaveBeenCalled())
})
it('FE-JRN-ADDTRIP-008: reports a failed link and re-enables the row', async () => {
server.use(http.post('/api/journeys/4/trips', () => new HttpResponse(null, { status: 500 })))
const user = userEvent.setup()
const { onAdded } = mountDialog()
await screen.findByText('Italy Roadtrip')
await user.click(screen.getAllByRole('button', { name: 'Link' })[0])
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith('Failed to link trip', 'error', undefined)
})
expect(onAdded).not.toHaveBeenCalled()
expect(screen.getAllByRole('button', { name: 'Link' })[0]).toBeEnabled()
})
it('FE-JRN-ADDTRIP-009: closes via the header button and survives a failed trip fetch', async () => {
server.use(http.get('/api/journeys/available-trips', () => new HttpResponse(null, { status: 500 })))
const { onClose } = mountDialog()
expect(await screen.findByText('No trips available')).toBeInTheDocument()
const headerClose = screen.getByRole('heading', { name: 'Link Trip' })
.parentElement!.querySelector('button')!
act(() => { headerClose.click() })
expect(onClose).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,239 @@
// FE-JRN-CARD-001 to FE-JRN-CARD-017
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { usePluginStore } from '../../store/pluginStore'
import type { JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { EntryCard, SkeletonCard, CheckinCard } from './JourneyDetailPageEntryCard'
function buildPhoto(id: number): JourneyPhoto {
return { id, entry_id: 10, photo_id: id, caption: null, sort_order: 0, shared: 1, created_at: 0 }
}
function buildEntry(overrides: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id: 10,
journey_id: 1,
author_id: 1,
type: 'entry',
entry_date: '2026-03-15',
title: 'Arrived in Rome',
story: null,
location_name: 'Rome, Italy',
entry_time: '10:00',
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
...overrides,
}
}
function mountCard(entry: JourneyEntry, readOnly = false) {
const onEdit = vi.fn()
const onDelete = vi.fn()
const onPhotoClick = vi.fn()
const utils = render(
<EntryCard entry={entry} readOnly={readOnly} onEdit={onEdit} onDelete={onDelete} onPhotoClick={onPhotoClick} />,
)
return { ...utils, onEdit, onDelete, onPhotoClick }
}
beforeEach(() => {
usePluginStore.setState({ plugins: [], loaded: true })
})
describe('EntryCard', () => {
it('FE-JRN-CARD-001: renders the header layout for an entry without photos', () => {
mountCard(buildEntry())
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument()
expect(screen.getByText('Rome, Italy')).toBeInTheDocument()
expect(screen.getByText('10:00')).toBeInTheDocument()
})
it('FE-JRN-CARD-002: renders the photo hero with the title overlaid', () => {
const { container } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
const img = container.querySelector('img[src="/api/photos/100/thumbnail"]')
expect(img).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Arrived in Rome' })).toBeInTheDocument()
})
it('FE-JRN-CARD-003: forwards a photo click with the photo list and index', async () => {
const user = userEvent.setup()
const photos = [buildPhoto(100), buildPhoto(101)]
const { container, onPhotoClick } = mountCard(buildEntry({ photos }))
await user.click(container.querySelector('img[src="/api/photos/101/thumbnail"]') as HTMLElement)
expect(onPhotoClick).toHaveBeenCalledWith(photos, 1)
})
it('FE-JRN-CARD-004: opens the photo-card menu and triggers edit', async () => {
const user = userEvent.setup()
const { container, onEdit } = mountCard(buildEntry({ photos: [buildPhoto(100)] }))
await user.click(container.querySelectorAll('button')[0])
await user.click(screen.getByRole('button', { name: 'Edit' }))
expect(onEdit).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
})
it('FE-JRN-CARD-005: opens the header menu and triggers delete', async () => {
const user = userEvent.setup()
const { container, onDelete } = mountCard(buildEntry())
await user.click(container.querySelectorAll('button')[0])
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('FE-JRN-CARD-006: closes the menu again when the backdrop is clicked', async () => {
const user = userEvent.setup()
const { container } = mountCard(buildEntry())
await user.click(container.querySelectorAll('button')[0])
const backdrop = document.querySelector('.fixed.inset-0.z-\\[99\\]') as HTMLElement
expect(backdrop).toBeInTheDocument()
await user.click(backdrop)
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
})
it('FE-JRN-CARD-007: hides the menu entirely in read-only mode', () => {
const { container } = mountCard(buildEntry({ photos: [buildPhoto(100)] }), true)
expect(container.querySelectorAll('button')).toHaveLength(0)
})
it('FE-JRN-CARD-008: renders mood, weather and tags in the meta row', () => {
mountCard(buildEntry({ mood: 'amazing', weather: 'sunny', tags: ['culture', 'food'] }))
expect(screen.getByText('Amazing')).toBeInTheDocument()
expect(screen.getByText('Sunny')).toBeInTheDocument()
expect(screen.getByText('culture')).toBeInTheDocument()
expect(screen.getByText('food')).toBeInTheDocument()
})
it('FE-JRN-CARD-009: renders the pros/cons verdict and the story body', () => {
mountCard(buildEntry({ story: 'A wonderful evening', pros_cons: { pros: ['Great food'], cons: ['Crowded'] } }))
expect(screen.getByText('A wonderful evening')).toBeInTheDocument()
expect(screen.getByText('Great food')).toBeInTheDocument()
expect(screen.getByText('Crowded')).toBeInTheDocument()
})
it('FE-JRN-CARD-010: skips the plugin request while no plugins are active', async () => {
let called = 0
server.use(http.get('/api/journal-entry-rows/10', () => {
called += 1
return HttpResponse.json({ providers: [] })
}))
mountCard(buildEntry())
await waitFor(() => expect(screen.getByText('Arrived in Rome')).toBeInTheDocument())
expect(called).toBe(0)
})
it('FE-JRN-CARD-011: renders plugin rows as text and links, dropping empty providers', async () => {
usePluginStore.setState({
plugins: [{ id: 'koffi', name: 'Koffi', type: 'widget', icon: null }],
loaded: true,
})
server.use(http.get('/api/journal-entry-rows/10', () => HttpResponse.json({
providers: [
{ pluginId: 'koffi', items: [{ label: 'Coffee', value: 'Espresso' }, { label: 'Shop', value: 'Sant Eustachio', url: 'https://example.com' }] },
{ pluginId: 'empty', items: [] },
],
})))
mountCard(buildEntry())
expect(await screen.findByText('Espresso')).toBeInTheDocument()
const link = screen.getByRole('link', { name: 'Sant Eustachio' })
expect(link).toHaveAttribute('href', 'https://example.com')
expect(screen.queryByText('empty')).not.toBeInTheDocument()
})
it('FE-JRN-CARD-012: renders no plugin rows when the request fails', async () => {
usePluginStore.setState({
plugins: [{ id: 'koffi', name: 'Koffi', type: 'widget', icon: null }],
loaded: true,
})
server.use(http.get('/api/journal-entry-rows/10', () => new HttpResponse(null, { status: 500 })))
const { container } = mountCard(buildEntry())
await waitFor(() => expect(screen.getByText('Arrived in Rome')).toBeInTheDocument())
expect(container.querySelectorAll('a')).toHaveLength(0)
})
it('FE-JRN-CARD-013: falls back to the entry URL when a plugin row has no value', async () => {
usePluginStore.setState({
plugins: [{ id: 'koffi', name: 'Koffi', type: 'widget', icon: null }],
loaded: true,
})
server.use(http.get('/api/journal-entry-rows/10', () => HttpResponse.json({
providers: [{ pluginId: 'koffi', items: [{ label: 'Booking', url: 'https://book.example' }] }],
})))
mountCard(buildEntry())
expect(await screen.findByRole('link', { name: 'https://book.example' })).toBeInTheDocument()
})
})
describe('SkeletonCard', () => {
it('FE-JRN-CARD-014: shows the placeholder title and the add CTA when clickable', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(<SkeletonCard entry={buildEntry({ title: null })} onClick={onClick} />)
expect(screen.getByText('New Entry')).toBeInTheDocument()
expect(screen.getByText('Rome, Italy · 10:00')).toBeInTheDocument()
await user.click(screen.getByText('Add Entry'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-JRN-CARD-015: omits the add CTA when no click handler is given', () => {
render(<SkeletonCard entry={buildEntry({ entry_time: null })} />)
expect(screen.getByText('Arrived in Rome')).toBeInTheDocument()
expect(screen.queryByText('Add Entry')).not.toBeInTheDocument()
expect(screen.getByText('Rome, Italy')).toBeInTheDocument()
})
})
describe('CheckinCard', () => {
it('FE-JRN-CARD-016: renders title, location, story and time', async () => {
const user = userEvent.setup()
const onClick = vi.fn()
render(
<CheckinCard
entry={buildEntry({ type: 'checkin', title: 'Quick stop', story: 'Espresso', entry_time: '15:30' })}
onClick={onClick}
/>,
)
expect(screen.getByText('Quick stop')).toBeInTheDocument()
expect(screen.getByText('· Rome, Italy')).toBeInTheDocument()
expect(screen.getByText('Espresso')).toBeInTheDocument()
await user.click(screen.getByText('15:30'))
expect(onClick).toHaveBeenCalledTimes(1)
})
it('FE-JRN-CARD-017: renders a bare check-in without location, story or time', () => {
const { container } = render(
<CheckinCard entry={buildEntry({ type: 'checkin', title: 'Bare stop', location_name: null, entry_time: null })} />,
)
expect(screen.getByText('Bare stop')).toBeInTheDocument()
expect(container.querySelector('.cursor-pointer')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,839 @@
// FE-JRN-EDITOR-001 to FE-JRN-EDITOR-040
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import type { GalleryPhoto, JourneyEntry, JourneyPhoto, JourneyTrip } from '../../store/journeyStore'
import type { ResilientResult, UploadProgress } from '../../utils/uploadQueue'
import { EntryEditor } from './JourneyDetailPageEntryEditor'
type ToastKind = 'success' | 'error' | 'warning' | 'info'
const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0)
const trips: JourneyTrip[] = [
{ trip_id: 5, added_at: 0, title: 'Italy Trip', start_date: '2026-03-14', end_date: '2026-03-20', place_count: 3 },
]
function buildEntry(overrides: Partial<JourneyEntry> = {}): JourneyEntry {
return {
id: 0,
journey_id: 1,
author_id: 1,
type: 'entry',
entry_date: '2026-03-15',
entry_time: '',
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
...overrides,
}
}
function buildPhoto(id: number): JourneyPhoto {
return { id, entry_id: 10, photo_id: id, caption: null, sort_order: 0, shared: 1, created_at: 0 }
}
function buildGalleryPhoto(id: number): GalleryPhoto {
return { id, journey_id: 1, photo_id: id, caption: null, shared: 1, sort_order: 0, created_at: 0 }
}
function mountEditor(
entry: JourneyEntry,
opts: { galleryPhotos?: GalleryPhoto[]; withProviderHook?: boolean } = {},
) {
const onClose = vi.fn()
const onDone = vi.fn()
const onSave = vi.fn(async (_data: Record<string, unknown>, _existingEntryId?: number) => 55)
const onUploadPhotos = vi.fn(
async (_entryId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => {
cbs?.onProgress?.({ done: files.length, total: files.length, failed: 0, percent: 100 })
return { succeeded: [] as JourneyPhoto[], failed: [] as File[] }
},
)
const onAddProviderPhotos = vi.fn(async () => {})
const utils = render(
<EntryEditor
entry={entry}
journeyId={1}
tripDates={new Set(['2026-03-15'])}
galleryPhotos={opts.galleryPhotos ?? []}
trips={trips}
userId={42}
onClose={onClose}
onSave={onSave}
onUploadPhotos={onUploadPhotos}
onAddProviderPhotos={opts.withProviderHook === false ? undefined : onAddProviderPhotos}
onDone={onDone}
/>,
)
return { ...utils, onClose, onDone, onSave, onUploadPhotos, onAddProviderPhotos }
}
function useConnectedImmich() {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }],
})),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
hasMore: false,
})),
)
}
const originalCreateObjectURL = URL.createObjectURL
beforeEach(() => {
toastSpy.mockClear()
window.__addToast = toastSpy
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: vi.fn(() => 'blob:preview'),
})
})
afterEach(() => {
delete window.__addToast
Object.defineProperty(URL, 'createObjectURL', {
configurable: true, writable: true, value: originalCreateObjectURL,
})
})
describe('EntryEditor', () => {
it('FE-JRN-EDITOR-001: opens as a new entry with empty fields', () => {
mountEditor(buildEntry())
expect(screen.getByRole('heading', { name: 'New Entry' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('Give this moment a name...')).toHaveValue('')
expect(screen.getByPlaceholderText('Write your story...')).toHaveValue('')
expect(screen.getByPlaceholderText('Search location...')).toHaveValue('')
})
it('FE-JRN-EDITOR-002: seeds every field from an existing entry', () => {
mountEditor(buildEntry({
id: 10, title: 'Arrived in Rome', story: 'Amazing city', location_name: 'Rome',
location_lat: 41.9, location_lng: 12.5, mood: 'amazing', weather: 'sunny',
pros_cons: { pros: ['Great food'], cons: ['Crowded'] },
}))
expect(screen.getByRole('heading', { name: 'Edit Entry' })).toBeInTheDocument()
expect(screen.getByDisplayValue('Arrived in Rome')).toBeInTheDocument()
expect(screen.getByDisplayValue('Amazing city')).toBeInTheDocument()
expect(screen.getByDisplayValue('Rome')).toBeInTheDocument()
expect(screen.getByDisplayValue('Great food')).toBeInTheDocument()
expect(screen.getByDisplayValue('Crowded')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Amazing' }).className).not.toContain('border-zinc-200')
})
it('FE-JRN-EDITOR-003: saves the edited fields and finishes', async () => {
const user = userEvent.setup()
const { onSave, onDone } = mountEditor(buildEntry({ id: 10, title: 'Old' }))
await user.clear(screen.getByDisplayValue('Old'))
await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome')
await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Rome',
story: 'Great day',
entry_date: '2026-03-15',
entry_time: null,
location_name: null,
mood: null,
weather: null,
pros_cons: { pros: [], cons: [] },
type: undefined,
}),
10,
)
})
it('FE-JRN-EDITOR-004: promotes a skeleton to a real entry once it has a story', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 21, type: 'skeleton', title: 'Venice' }))
await user.type(screen.getByPlaceholderText('Write your story...'), 'Gondolas')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' })
})
it('FE-JRN-EDITOR-005: uploads files queued before the save', async () => {
const user = userEvent.setup()
const { container, onUploadPhotos, onDone } = mountEditor(buildEntry())
const file = new File(['a'], 'a.jpg', { type: 'image/jpeg' })
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(onUploadPhotos).toHaveBeenCalledWith(55, [file], expect.anything())
})
it('FE-JRN-EDITOR-006: keeps the files that failed to upload and warns', async () => {
const user = userEvent.setup()
const { container, onUploadPhotos } = mountEditor(buildEntry())
const file = new File(['a'], 'a.jpg', { type: 'image/jpeg' })
onUploadPhotos.mockResolvedValueOnce({ succeeded: [], failed: [file] })
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [file] } })
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith('1 of 1 photos failed — save again to retry', 'error', undefined)
})
expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-007: reports a rejected upload', async () => {
const user = userEvent.setup()
const { container, onUploadPhotos, onDone } = mountEditor(buildEntry())
onUploadPhotos.mockRejectedValueOnce(new Error('disk full'))
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('disk full', 'error', undefined))
expect(onDone).toHaveBeenCalledTimes(1)
})
it('FE-JRN-EDITOR-008: drops a queued file again from the preview strip', async () => {
const user = userEvent.setup()
const { container } = mountEditor(buildEntry())
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
const preview = container.querySelector('img[src="blob:preview"]') as HTMLElement
await user.click(preview.parentElement!.querySelector('button') as HTMLElement)
expect(container.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-009: links gallery photos picked before the entry exists', async () => {
const linked: string[] = []
server.use(http.post('/api/journeys/entries/55/link-photo', async ({ request }) => {
linked.push(JSON.stringify(await request.json()))
return HttpResponse.json({ id: 200 })
}))
const user = userEvent.setup()
const { onDone } = mountEditor(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(linked).toEqual([JSON.stringify({ journey_photo_id: 200 })])
})
it('FE-JRN-EDITOR-010: links a gallery photo straight away on an existing entry', async () => {
let linked = false
server.use(http.post('/api/journeys/entries/10/link-photo', () => {
linked = true
return HttpResponse.json({ id: 200, entry_id: 10, photo_id: 200, sort_order: 0, shared: 1, created_at: 0 })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10 }), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await waitFor(() => expect(linked).toBe(true))
// The linked photo joins the strip, so the picker reports nothing left.
expect(container.querySelectorAll('img[src="/api/photos/200/thumbnail"]').length).toBeGreaterThan(0)
expect(await screen.findByText('All photos already added')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-011: unlinks a removed photo from an existing entry', async () => {
let unlinked = false
server.use(http.delete('/api/journeys/entries/10/photos/100', () => {
unlinked = true
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100)] }))
const tile = container.querySelector('img[src="/api/photos/100/thumbnail"]')!.parentElement as HTMLElement
await user.click(tile.querySelector('button') as HTMLElement)
await waitFor(() => expect(unlinked).toBe(true))
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-012: promoting a photo to first persists the new sort order', async () => {
const patched: Array<{ id: string; body: unknown }> = []
server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => {
patched.push({ id: String(params.id), body: await request.json() })
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
expect(screen.getByText('1st')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
await waitFor(() => expect(patched).toHaveLength(2))
expect(patched).toEqual([
{ id: '101', body: { sort_order: 0 } },
{ id: '100', body: { sort_order: 1 } },
])
const imgs = Array.from(container.querySelectorAll('img')).map(i => i.getAttribute('src'))
expect(imgs[0]).toBe('/api/photos/101/thumbnail')
})
it('FE-JRN-EDITOR-013: asks before discarding a dirty editor', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const user = userEvent.setup()
const { onClose } = mountEditor(buildEntry({ id: 10, title: 'Rome' }))
await user.type(screen.getByDisplayValue('Rome'), '!')
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?')
expect(onClose).not.toHaveBeenCalled()
confirmSpy.mockReturnValue(true)
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledTimes(1)
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-014: closes an untouched editor without asking', async () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const user = userEvent.setup()
const { onClose } = mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'Cancel' }))
expect(confirmSpy).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
confirmSpy.mockRestore()
})
it('FE-JRN-EDITOR-015: adds and removes pro and con rows', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10 }))
const [addPro, addCon] = screen.getAllByRole('button', { name: 'Add another' })
await user.click(addPro)
await user.click(addCon)
const proInputs = screen.getAllByPlaceholderText('Something great...')
expect(proInputs).toHaveLength(2)
await user.type(proInputs[0], 'Food')
await user.type(screen.getAllByPlaceholderText('Not so great...')[1], 'Queues')
// Each extra row gets its own delete control once more than one exists.
const removeSecondPro = proInputs[1].parentElement!.querySelector('button') as HTMLElement
await user.click(removeSecondPro)
expect(screen.getAllByPlaceholderText('Something great...')).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ pros_cons: { pros: ['Food'], cons: ['Queues'] } })
})
it('FE-JRN-EDITOR-016: toggles mood and weather chips on and off', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'Neutral' }))
await user.click(screen.getByRole('button', { name: 'Rainy' }))
expect(screen.getByRole('button', { name: 'Rainy' }).className).toContain('bg-zinc-900')
await user.click(screen.getByRole('button', { name: 'Rainy' }))
expect(screen.getByRole('button', { name: 'Rainy' }).className).not.toContain('bg-zinc-900')
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ mood: 'neutral', weather: null })
})
it('FE-JRN-EDITOR-017: picks a searched location and stores its coordinates', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({
places: [{ name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: 41.9, lng: 12.5 }],
})))
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 10 }))
fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } })
const result = await screen.findByText('Roma Termini', {}, { timeout: 3000 })
expect(screen.getByText('Piazza dei Cinquecento')).toBeInTheDocument()
await user.click(result)
expect(screen.getByDisplayValue('Roma Termini')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ location_name: 'Roma Termini', location_lat: 41.9, location_lng: 12.5 })
})
it('FE-JRN-EDITOR-018: shows no suggestions when the location search fails', async () => {
let calls = 0
server.use(http.post('/api/maps/search', () => {
calls += 1
return new HttpResponse(null, { status: 500 })
}))
mountEditor(buildEntry({ id: 10 }))
const input = screen.getByPlaceholderText('Search location...')
// A one-character query never reaches the network at all.
fireEvent.change(input, { target: { value: 'R' } })
fireEvent.change(input, { target: { value: 'Roma' } })
await waitFor(() => expect(calls).toBe(1), { timeout: 3000 })
await waitFor(() => expect(screen.queryByText('Searching...')).not.toBeInTheDocument())
expect(screen.getByDisplayValue('Roma')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-022: dismisses the suggestion list and brings it back on focus', async () => {
server.use(http.post('/api/maps/search', () => HttpResponse.json({
places: [{ name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: 41.9, lng: 12.5 }],
})))
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
const input = screen.getByPlaceholderText('Search location...')
fireEvent.change(input, { target: { value: 'Roma' } })
await screen.findByText('Roma Termini', {}, { timeout: 3000 })
await user.click(document.querySelector('.fixed.inset-0.z-\\[99\\]') as HTMLElement)
expect(screen.queryByText('Roma Termini')).not.toBeInTheDocument()
fireEvent.focus(input)
expect(screen.getByText('Roma Termini')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019: reports that no external provider is connected', async () => {
server.use(http.get('/api/addons', () => HttpResponse.json({})))
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByText('No connected photo providers are available.')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-019b: leaving the external tab while the providers load does not strand the picker', async () => {
server.use(
http.get('/api/addons', async () => {
await delay(120)
return HttpResponse.json({
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }],
})
}),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [], hasMore: false })),
)
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
await user.click(screen.getByRole('button', { name: /Upload photos/ }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-020: queues photos picked from a connected provider and clears them again', async () => {
useConnectedImmich()
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10, location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
expect(screen.getByText('Nearby photos first · Rome')).toBeInTheDocument()
expect(screen.getByText('Photos for Mar 15, 2026')).toBeInTheDocument()
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
const clearBtn = await screen.findByRole('button', { name: /1 queued · Clear/ })
await user.click(clearBtn)
expect(screen.queryByRole('button', { name: /queued/ })).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-021: sends queued provider photos on save and keeps the failed ones', async () => {
useConnectedImmich()
const user = userEvent.setup()
const { onAddProviderPhotos, onDone } = mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByText('All photos from this day')).toBeInTheDocument()
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
onAddProviderPhotos.mockRejectedValueOnce(new Error('provider down'))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith('1 photo groups failed — save again to retry', 'error', undefined)
})
expect(onDone).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(onAddProviderPhotos).toHaveBeenLastCalledWith(55, expect.objectContaining({
provider: 'immich',
assetIds: ['asset-1'],
}))
})
it('FE-JRN-EDITOR-023: the upload button closes the gallery picker and an empty pick is ignored', async () => {
const user = userEvent.setup()
const { container } = mountEditor(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
expect(screen.getAllByAltText('')).toHaveLength(1)
await user.click(screen.getByRole('button', { name: 'Upload photos' }))
expect(screen.queryByAltText('')).not.toBeInTheDocument()
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, { target: { files: [] } })
expect(container.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-024: falls back to today and an empty strip when the entry carries neither', () => {
const { container } = mountEditor(buildEntry({
entry_date: '', photos: undefined as unknown as JourneyPhoto[],
}))
const today = new Date().toISOString().split('T')[0]
const label = new Date(today + 'T00:00:00').toLocaleDateString(undefined, {
month: 'short', day: 'numeric', year: 'numeric',
})
expect(screen.getByText(label)).toBeInTheDocument()
expect(container.querySelector('.w-20.h-20')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-025: skips the photo upload when the save does not yield an entry id', async () => {
const user = userEvent.setup()
const { container, onSave, onUploadPhotos, onDone } = mountEditor(buildEntry())
onSave.mockResolvedValueOnce(0)
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(onUploadPhotos).not.toHaveBeenCalled()
})
it('FE-JRN-EDITOR-026: leaves a skeleton without any content a skeleton', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({ id: 21, type: 'skeleton', title: 'Venice' }))
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ type: undefined })
})
it('FE-JRN-EDITOR-027: removes a con row again', async () => {
const user = userEvent.setup()
const { onSave } = mountEditor(buildEntry({
id: 10, pros_cons: { pros: ['Food'], cons: ['Queues', 'Heat'] },
}))
const row = screen.getByDisplayValue('Heat').parentElement as HTMLElement
await user.click(row.querySelector('button') as HTMLElement)
expect(screen.queryByDisplayValue('Heat')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onSave).toHaveBeenCalled())
expect(onSave.mock.calls[0][0]).toMatchObject({ pros_cons: { pros: ['Food'], cons: ['Queues'] } })
})
it('FE-JRN-EDITOR-028: a broken thumbnail retries once against the original size', async () => {
const user = userEvent.setup()
const { container } = mountEditor(
buildEntry({ id: 10, photos: [buildPhoto(100)] }),
{ galleryPhotos: [buildGalleryPhoto(200)] },
)
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
const galleryImg = container.querySelector('img[src="/api/photos/200/thumbnail"]') as HTMLImageElement
fireEvent.error(galleryImg)
expect(galleryImg.getAttribute('src')).toBe('/api/photos/200/original')
// A second failure must not loop back onto the thumbnail.
fireEvent.error(galleryImg)
expect(galleryImg.getAttribute('src')).toBe('/api/photos/200/original')
const stripImg = container.querySelector('img[src="/api/photos/100/thumbnail"]') as HTMLImageElement
fireEvent.error(stripImg)
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
fireEvent.error(stripImg)
expect(stripImg.getAttribute('src')).toBe('/api/photos/100/original')
})
it('FE-JRN-EDITOR-029: reorders photos locally even when persisting the order fails', async () => {
let attempts = 0
server.use(http.patch('/api/journeys/photos/:id', () => {
attempts += 1
return new HttpResponse(null, { status: 500 })
}))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10, photos: [buildPhoto(100), buildPhoto(101)] }))
await user.click(screen.getByRole('button', { name: 'Make 1st' }))
await waitFor(() => expect(attempts).toBe(2))
const order = Array.from(container.querySelectorAll('.w-20.h-20 img')).map(i => i.getAttribute('src'))
expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail'])
})
it('FE-JRN-EDITOR-030: dropping an unsaved gallery pick cancels its link', async () => {
const linked: string[] = []
server.use(http.post('/api/journeys/entries/55/link-photo', async ({ request }) => {
linked.push(JSON.stringify(await request.json()))
return HttpResponse.json({ id: 200 })
}))
const user = userEvent.setup()
const { container, onDone } = mountEditor(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await screen.findByText('All photos already added')
const tile = container.querySelector('.w-20.h-20') as HTMLElement
await user.click(tile.querySelector('button') as HTMLElement)
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(linked).toEqual([])
})
it('FE-JRN-EDITOR-031: keeps a gallery photo in the picker when linking it fails', async () => {
let attempts = 0
server.use(http.post('/api/journeys/entries/10/link-photo', () => {
attempts += 1
return new HttpResponse(null, { status: 500 })
}))
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await waitFor(() => expect(attempts).toBe(1))
expect(screen.queryByText('All photos already added')).not.toBeInTheDocument()
expect(screen.getAllByAltText('')).toHaveLength(1)
})
it('FE-JRN-EDITOR-032: only offers enabled and connected photo providers', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [
{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true },
{ id: 'photoprism', name: 'PhotoPrism', type: 'photo_provider', icon: 'camera', enabled: true },
{ id: 'nas', name: 'NAS', type: 'photo_provider', icon: 'camera', enabled: false },
{ id: 'offline', name: 'Offline', type: 'photo_provider', icon: 'camera', enabled: true },
{ id: 'broken', name: 'Broken', type: 'photo_provider', icon: 'camera', enabled: true },
{ id: 'budget', name: 'Budget', type: 'feature', icon: 'wallet', enabled: true },
],
})),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/photoprism/status', () => HttpResponse.json({ connected: true })),
http.get('/api/integrations/memories/offline/status', () => HttpResponse.json({ connected: false })),
http.get('/api/integrations/memories/broken/status', () => HttpResponse.error()),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' }],
hasMore: false,
})),
http.post('/api/integrations/memories/photoprism/search', () => HttpResponse.json({ assets: [], hasMore: false })),
)
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
expect(screen.getByTestId('journey-external-provider-photoprism')).toBeInTheDocument()
expect(screen.queryByTestId('journey-external-provider-nas')).not.toBeInTheDocument()
expect(screen.queryByTestId('journey-external-provider-offline')).not.toBeInTheDocument()
expect(screen.queryByTestId('journey-external-provider-broken')).not.toBeInTheDocument()
// Queue a photo from the first provider, then switch tabs.
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
await user.click(screen.getByTestId('journey-external-provider-photoprism'))
expect(screen.getByTestId('journey-external-provider-photoprism').className).toContain('bg-zinc-900')
// The picker's own cancel drops back to the first available provider.
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0])
expect(screen.getByTestId('journey-external-provider-photoprism').className).not.toContain('bg-zinc-900')
expect(screen.getByRole('button', { name: /1 queued · Clear/ })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-033: merges a second pick into the already queued group', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }],
})),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [
{ id: 'asset-1', takenAt: '2026-03-15T09:00:00.000Z', mediaType: 'image' },
{ id: 'asset-2', takenAt: '2026-03-15T10:00:00.000Z', mediaType: 'video' },
],
hasMore: false,
})),
)
const user = userEvent.setup()
const { onAddProviderPhotos, onDone } = mountEditor(buildEntry({ id: 10 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
const tiles = await screen.findAllByAltText('')
const assetIdOf = (img: HTMLElement) => img.getAttribute('src')!.split('/assets/0/')[1].split('/')[0]
const firstId = assetIdOf(tiles[0])
await user.click(tiles[0])
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await screen.findByRole('button', { name: /1 queued · Clear/ })
// The first asset stays selected but is now greyed out, so the second Add
// re-sends it and the merge has to skip the duplicate.
const remaining = screen.getAllByAltText('').find(img => !img.parentElement!.className.includes('opacity-40'))!
const secondId = assetIdOf(remaining)
await user.click(remaining)
await user.click(screen.getByRole('button', { name: 'Add (2)' }))
expect(await screen.findByRole('button', { name: /2 queued · Clear/ })).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
expect(onAddProviderPhotos).toHaveBeenCalledTimes(1)
expect(onAddProviderPhotos).toHaveBeenCalledWith(55, expect.objectContaining({
provider: 'immich',
assetIds: [firstId, secondId],
}))
})
it('FE-JRN-EDITOR-034: greys out provider assets already linked to the entry', async () => {
useConnectedImmich()
const user = userEvent.setup()
mountEditor(buildEntry({
id: 10,
// The locally uploaded photo carries no provider and must be ignored here.
photos: [buildPhoto(99), { ...buildPhoto(100), provider: 'immich', asset_id: 'asset-1' }],
}))
await user.click(screen.getByRole('button', { name: 'External photos' }))
await screen.findByTestId('journey-external-provider-immich')
await waitFor(() => expect(document.querySelector('.opacity-40')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: /Select all/ })).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-035: falls back to the day-wide provider search without a place name', async () => {
useConnectedImmich()
const user = userEvent.setup()
mountEditor(buildEntry({ location_lat: 41.9, location_lng: 12.5 }))
await user.click(screen.getByRole('button', { name: 'External photos' }))
expect(await screen.findByText('All photos from this day')).toBeInTheDocument()
expect(await screen.findByTestId('journey-external-provider-immich')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-040: ignores a link call that comes back without a photo', async () => {
server.use(http.post('/api/journeys/entries/10/link-photo', () => HttpResponse.json(null)))
const user = userEvent.setup()
const { container } = mountEditor(buildEntry({ id: 10 }), { galleryPhotos: [buildGalleryPhoto(200)] })
await user.click(screen.getByRole('button', { name: 'From Gallery' }))
await user.click(screen.getAllByAltText('')[0])
await waitFor(() => expect(screen.getAllByAltText('')).toHaveLength(1))
expect(container.querySelector('.w-20.h-20')).not.toBeInTheDocument()
})
it('FE-JRN-EDITOR-036: shows the upload progress while photos are in flight', async () => {
const user = userEvent.setup()
const { container, onUploadPhotos, onDone } = mountEditor(buildEntry())
let release: (r: ResilientResult<JourneyPhoto>) => void = () => {}
onUploadPhotos.mockImplementationOnce((_entryId, files, cbs) =>
new Promise<ResilientResult<JourneyPhoto>>(resolve => {
cbs?.onProgress?.({ done: 1, total: files.length, failed: 0, percent: 50 })
release = resolve
}))
fireEvent.change(container.querySelector('input[type="file"]') as HTMLInputElement, {
target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] },
})
await waitFor(() => expect(container.querySelector('img[src="blob:preview"]')).toBeInTheDocument())
await user.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Uploading 1/1…')).toBeInTheDocument()
release({ succeeded: [], failed: [] })
await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1))
})
it('FE-JRN-EDITOR-037: a fresh keystroke cancels the pending search and an empty payload yields nothing', async () => {
let calls = 0
server.use(http.post('/api/maps/search', () => {
calls += 1
return HttpResponse.json({})
}))
mountEditor(buildEntry({ id: 10 }))
const input = screen.getByPlaceholderText('Search location...')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'Ro' } })
fireEvent.change(input, { target: { value: 'Rom' } })
await waitFor(() => expect(calls).toBe(1), { timeout: 3000 })
await waitFor(() => expect(screen.queryByText('Searching...')).not.toBeInTheDocument())
expect(screen.getByDisplayValue('Rom')).toBeInTheDocument()
})
it('FE-JRN-EDITOR-038: shows a searching hint while the location lookup runs', async () => {
server.use(http.post('/api/maps/search', async () => {
await delay(200)
return HttpResponse.json({ places: [{ name: 'Roma Termini', lat: 41.9, lng: 12.5 }] })
}))
mountEditor(buildEntry({ id: 10 }))
fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } })
expect(await screen.findByText('Searching...', {}, { timeout: 3000 })).toBeInTheDocument()
expect(await screen.findByText('Roma Termini', {}, { timeout: 3000 })).toBeInTheDocument()
})
it('FE-JRN-EDITOR-039: highlights the picked mood with its own palette', async () => {
const user = userEvent.setup()
mountEditor(buildEntry({ id: 10 }))
const amazing = screen.getByRole('button', { name: 'Amazing' })
expect(amazing.getAttribute('style')).toBeNull()
await user.click(amazing)
expect(amazing.className).not.toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBeTruthy()
await user.click(amazing)
expect(amazing.className).toContain('border-zinc-200')
expect(amazing.getAttribute('style')).toBe('')
})
})
@@ -86,8 +86,10 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
useEffect(() => {
if (photoTab !== 'external' || availableProviders.length > 0 || providersLoading) return
let cancelled = false
setProvidersLoading(true)
// The discovery is not tied to the open tab, so the result is applied even if
// the user left the tab meanwhile — dropping it would leave providersLoading
// stuck and block every later run of this effect.
;(async () => {
try {
const addonsData = await addonsApi.enabled()
@@ -99,14 +101,11 @@ export function EntryEditor({ entry, journeyId, tripDates, galleryPhotos, trips,
if (response.ok && (await response.json()).connected) connected.push({ id: provider.id, name: provider.name })
} catch {}
}
if (!cancelled) {
setAvailableProviders(connected)
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
}
setAvailableProviders(connected)
if (connected.length > 0) setExternalProvider(current => current || connected[0].id)
} catch {}
if (!cancelled) setProvidersLoading(false)
setProvidersLoading(false)
})()
return () => { cancelled = true }
}, [photoTab, availableProviders.length])
const activeExternalProvider = externalProvider || availableProviders[0]?.id || null
@@ -0,0 +1,306 @@
// FE-JRN-GALLERY-001 to FE-JRN-GALLERY-016
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import userEvent from '@testing-library/user-event'
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
import { server } from '../../../tests/helpers/msw/server'
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyTrip } from '../../store/journeyStore'
import type { UploadProgress } from '../../utils/uploadQueue'
import { GalleryView } from './JourneyDetailPageGalleryView'
type ToastKind = 'success' | 'error' | 'warning' | 'info'
const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0)
const uploadGalleryPhotos = vi.fn(
async (_journeyId: number, files: File[], cbs?: { onProgress?: (p: UploadProgress) => void }) => {
cbs?.onProgress?.({ done: files.length, total: files.length, failed: 0, percent: 100 })
return { succeeded: [], failed: [] as File[] }
},
)
const JOURNEY_ID = 9
function buildGalleryPhoto(overrides: Partial<GalleryPhoto> = {}): GalleryPhoto {
return {
id: 100,
journey_id: JOURNEY_ID,
photo_id: 100,
caption: null,
shared: 1,
sort_order: 0,
created_at: 0,
provider: 'local',
...overrides,
}
}
const trips: JourneyTrip[] = [
{ trip_id: 5, added_at: 0, title: 'Italy Trip', start_date: '2026-03-14', end_date: '2026-03-20', place_count: 3 },
]
const entries: JourneyEntry[] = [
{
id: 10, journey_id: JOURNEY_ID, author_id: 1, type: 'entry', entry_date: '2026-03-15',
title: 'Arrived in Rome', visibility: 'private', sort_order: 0, photos: [], created_at: 0, updated_at: 0,
},
]
function mountGallery(gallery: GalleryPhoto[], onRegisterUpload?: (fn: () => void) => void) {
const onPhotoClick = vi.fn()
const onRefresh = vi.fn()
const utils = render(
<GalleryView
entries={entries}
gallery={gallery}
journeyId={JOURNEY_ID}
userId={1}
trips={trips}
onPhotoClick={onPhotoClick}
onRefresh={onRefresh}
onRegisterUpload={onRegisterUpload}
/>,
)
return { ...utils, onPhotoClick, onRefresh }
}
function useConnectedImmich() {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [
{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true },
{ id: 'synologyphotos', name: 'Synology Photos', type: 'photo_provider', icon: 'camera', enabled: false },
{ id: 'vacay', name: 'Vacay', type: 'feature', icon: 'calendar', enabled: true },
],
})),
http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })),
http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({
assets: [{ id: 'asset-1', takenAt: '2026-03-15T10:00:00.000Z', mediaType: 'image' }],
hasMore: false,
})),
)
}
beforeEach(() => {
toastSpy.mockClear()
uploadGalleryPhotos.mockClear()
window.__addToast = toastSpy
useJourneyStore.setState({ current: null, uploadGalleryPhotos })
})
afterEach(() => {
delete window.__addToast
})
describe('GalleryView', () => {
it('FE-JRN-GALLERY-001: shows the mascot empty state when the journey has no photos', async () => {
const { container } = mountGallery([])
expect(screen.getByText('No photos yet')).toBeInTheDocument()
expect(container.querySelector('.trek--journey')).toBeInTheDocument()
expect(screen.getByText('0 photos')).toBeInTheDocument()
})
it('FE-JRN-GALLERY-002: renders one tile per photo with the thumbnail URL', () => {
const { container } = mountGallery([buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })])
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('img[src="/api/photos/101/thumbnail"]')).toBeInTheDocument()
expect(screen.getByText('2 photos')).toBeInTheDocument()
})
it('FE-JRN-GALLERY-003: forwards the clicked photo index to the parent', async () => {
const user = userEvent.setup()
const gallery = [buildGalleryPhoto(), buildGalleryPhoto({ id: 101, photo_id: 101 })]
const { container, onPhotoClick } = mountGallery(gallery)
await user.click(container.querySelector('img[src="/api/photos/101/thumbnail"]')!.parentElement as HTMLElement)
expect(onPhotoClick).toHaveBeenCalledWith(gallery, 1)
})
it('FE-JRN-GALLERY-004: renders a neutral tile for a video without a poster', () => {
const { container } = mountGallery([buildGalleryPhoto({ media_type: 'video', thumbnail_path: null })])
expect(container.querySelector('img')).not.toBeInTheDocument()
// Videos still get the play overlay.
expect(container.querySelector('.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-GALLERY-005: renders the poster for a video that has one', () => {
const { container } = mountGallery([buildGalleryPhoto({ media_type: 'video', thumbnail_path: 'thumbs/a.jpg' })])
expect(container.querySelector('img[src="/api/photos/100/thumbnail"]')).toBeInTheDocument()
expect(container.querySelector('.lucide-play')).toBeInTheDocument()
})
it('FE-JRN-GALLERY-006: labels provider-backed photos and shows their caption', () => {
mountGallery([
buildGalleryPhoto({ id: 101, photo_id: 101, provider: 'immich', caption: 'Colosseum' }),
buildGalleryPhoto({ id: 102, photo_id: 102, provider: 'synologyphotos' }),
buildGalleryPhoto({ id: 103, photo_id: 103, provider: 'nextcloud' }),
buildGalleryPhoto({ id: 104, photo_id: 104, provider: 'local' }),
])
expect(screen.getByText('Immich')).toBeInTheDocument()
expect(screen.getByText('Synology Photos')).toBeInTheDocument()
expect(screen.getByText('nextcloud')).toBeInTheDocument()
expect(screen.getByText('Colosseum')).toBeInTheDocument()
})
it('FE-JRN-GALLERY-007: removes a deleted photo from the store optimistically', async () => {
let deleted = false
server.use(http.delete('/api/journeys/9/gallery/100', () => {
deleted = true
return HttpResponse.json({ ok: true })
}))
const photo = buildGalleryPhoto()
useJourneyStore.setState({
current: {
id: JOURNEY_ID, user_id: 1, title: 'Italy', status: 'active', created_at: 0, updated_at: 0,
entries: [{ ...entries[0], photos: [{ id: 100, entry_id: 10, photo_id: 100, sort_order: 0, shared: 1, created_at: 0 }] }],
gallery: [photo], trips, contributors: [], stats: { entries: 1, photos: 1, places: 0 },
},
})
const user = userEvent.setup()
const { container, onRefresh } = mountGallery([photo])
await user.click(container.querySelectorAll('button')[0])
await waitFor(() => expect(deleted).toBe(true))
const current = useJourneyStore.getState().current!
expect(current.gallery).toHaveLength(0)
expect(current.entries[0].photos).toHaveLength(0)
expect(onRefresh).not.toHaveBeenCalled()
})
it('FE-JRN-GALLERY-008: reports a failed delete and asks the parent to refetch', async () => {
server.use(http.delete('/api/journeys/9/gallery/100', () => new HttpResponse(null, { status: 500 })))
const photo = buildGalleryPhoto()
useJourneyStore.setState({
current: {
id: JOURNEY_ID, user_id: 1, title: 'Italy', status: 'active', created_at: 0, updated_at: 0,
entries: [], gallery: [photo], trips, contributors: [], stats: { entries: 0, photos: 1, places: 0 },
},
})
const user = userEvent.setup()
const { container, onRefresh } = mountGallery([photo])
await user.click(container.querySelectorAll('button')[0])
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined))
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('FE-JRN-GALLERY-009: ignores a delete while no journey is loaded', async () => {
let deleted = false
server.use(http.delete('/api/journeys/9/gallery/100', () => {
deleted = true
return HttpResponse.json({ ok: true })
}))
const user = userEvent.setup()
const { container } = mountGallery([buildGalleryPhoto()])
await user.click(container.querySelectorAll('button')[0])
expect(deleted).toBe(false)
})
it('FE-JRN-GALLERY-010: only offers providers that are enabled and connected', async () => {
useConnectedImmich()
mountGallery([])
expect(await screen.findByRole('button', { name: 'Immich' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Synology Photos' })).not.toBeInTheDocument()
})
it('FE-JRN-GALLERY-011: offers no providers when the status probe fails', async () => {
server.use(
http.get('/api/addons', () => HttpResponse.json({
addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', icon: 'camera', enabled: true }],
})),
http.get('/api/integrations/memories/immich/status', () => new HttpResponse(null, { status: 401 })),
)
mountGallery([])
await waitFor(() => expect(screen.getByText('No photos yet')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: 'Immich' })).not.toBeInTheDocument()
})
it('FE-JRN-GALLERY-012: adds picked provider photos to the gallery', async () => {
useConnectedImmich()
const bodies: Record<string, unknown>[] = []
server.use(http.post('/api/journeys/9/gallery/provider-photos', async ({ request }) => {
bodies.push(await request.json() as Record<string, unknown>)
return HttpResponse.json({ added: 1 })
}))
const user = userEvent.setup()
const { onRefresh } = mountGallery([])
await user.click(await screen.findByRole('button', { name: 'Immich' }))
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
expect(bodies[0]).toEqual({ provider: 'immich', asset_ids: ['asset-1'], media_types: ['image'] })
expect(toastSpy).toHaveBeenCalledWith('1 photos added', 'success', undefined)
expect(screen.queryByRole('heading', { name: 'Immich' })).not.toBeInTheDocument()
})
it('FE-JRN-GALLERY-013: reports when adding provider photos fails', async () => {
useConnectedImmich()
server.use(http.post('/api/journeys/9/gallery/provider-photos', () => new HttpResponse(null, { status: 500 })))
const user = userEvent.setup()
const { onRefresh } = mountGallery([])
await user.click(await screen.findByRole('button', { name: 'Immich' }))
await user.click(await screen.findByAltText(''))
await user.click(screen.getByRole('button', { name: 'Add (1)' }))
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Error', 'error', undefined))
expect(onRefresh).not.toHaveBeenCalled()
})
it('FE-JRN-GALLERY-014: uploads picked files and refreshes on success', async () => {
let registered: (() => void) | null = null
const { container, onRefresh } = mountGallery([], fn => { registered = fn })
expect(registered).toBeTypeOf('function')
const input = container.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [new File(['a'], 'a.jpg', { type: 'image/jpeg' })] } })
await waitFor(() => expect(onRefresh).toHaveBeenCalledTimes(1))
expect(uploadGalleryPhotos).toHaveBeenCalledTimes(1)
expect(toastSpy).toHaveBeenCalledWith('1 photos uploaded', 'success', undefined)
expect(input.value).toBe('')
})
it('FE-JRN-GALLERY-015: reports partially failed uploads but still refreshes', async () => {
const failedFile = new File(['b'], 'b.jpg', { type: 'image/jpeg' })
uploadGalleryPhotos.mockResolvedValueOnce({ succeeded: [], failed: [failedFile] })
const { container, onRefresh } = mountGallery([])
const input = container.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [failedFile] } })
await waitFor(() => {
expect(toastSpy).toHaveBeenCalledWith('1 of 1 photos failed — save again to retry', 'error', undefined)
})
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('FE-JRN-GALLERY-016: reports a rejected upload and ignores an empty selection', async () => {
uploadGalleryPhotos.mockRejectedValueOnce({ code: 'ERR_NETWORK' })
const { container, onRefresh } = mountGallery([])
const input = container.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [] } })
expect(uploadGalleryPhotos).not.toHaveBeenCalled()
fireEvent.change(input, { target: { files: [new File(['c'], 'c.jpg', { type: 'image/jpeg' })] } })
await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('Some photos failed to upload', 'error', undefined))
expect(onRefresh).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,175 @@
// FE-COMP-JMAPVIEW-001 to FE-COMP-JMAPVIEW-011
const lastMapProps = vi.hoisted(() => ({ current: {} as Record<string, unknown> }))
// The map renderer picks Leaflet or GL from the settings store and is covered
// by its own suites — here it is reduced to a props recorder.
vi.mock('./JourneyMapAuto', async () => {
const React = await import('react')
return {
default: React.forwardRef(function MockJourneyMapAuto(
props: Record<string, unknown>,
_ref: React.ForwardedRef<unknown>,
) {
lastMapProps.current = props
return React.createElement('div', { 'data-testid': 'journey-map' })
}),
}
})
import { createRef } from 'react'
import { render, screen, fireEvent, within } from '../../../tests/helpers/render'
import { resetAllStores } from '../../../tests/helpers/store'
import { MapView } from './JourneyDetailPageMapView'
import type { JourneyMapAutoHandle } from './JourneyMapAuto'
import type { JourneyEntry } from '../../store/journeyStore'
function buildEntry(overrides: Record<string, unknown>): JourneyEntry {
return {
id: 1,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Louvre',
story: null,
entry_date: '2025-06-01',
entry_time: null,
location_name: 'Paris, France',
location_lat: 48.86,
location_lng: 2.35,
mood: null,
weather: null,
tags: [],
pros_cons: null,
visibility: 'private',
sort_order: 0,
photos: [],
created_at: 0,
updated_at: 0,
...overrides,
} as unknown as JourneyEntry
}
const mapEntries = [
buildEntry({ id: 1, title: 'Louvre', entry_date: '2025-06-01', entry_time: '09:00' }),
buildEntry({ id: 2, title: 'Sacré-Cœur', entry_date: '2025-06-01', location_name: 'Montmartre, Paris, France' }),
buildEntry({ id: 3, title: 'Museumsinsel', entry_date: '2025-06-02', location_name: 'Berlin, Germany', location_lat: 52.52, location_lng: 13.4 }),
]
const allEntries = [
...mapEntries,
buildEntry({ id: 4, type: 'checkin', title: 'Checked in', entry_date: '2025-06-02' }),
]
function renderMapView(props: Partial<{
entries: JourneyEntry[]
mapEntries: JourneyEntry[]
activeLocationId: string | null
onLocationClick: (id: string) => void
}> = {}) {
const onLocationClick = props.onLocationClick ?? vi.fn()
const result = render(
<MapView
entries={props.entries ?? allEntries}
mapEntries={props.mapEntries ?? mapEntries}
sortedDates={['2025-06-01', '2025-06-02']}
activeLocationId={props.activeLocationId ?? null}
fullMapRef={createRef<JourneyMapAutoHandle>()}
onLocationClick={onLocationClick}
/>,
)
return { ...result, onLocationClick }
}
beforeEach(() => {
resetAllStores()
vi.clearAllMocks()
})
describe('JourneyDetailPage MapView', () => {
it('FE-COMP-JMAPVIEW-001: hands the located entries to the map as plain marker items', () => {
renderMapView()
expect(screen.getByTestId('journey-map')).toBeInTheDocument()
expect(lastMapProps.current).toMatchObject({ height: 560, activeMarkerId: null })
expect(lastMapProps.current.entries).toEqual([
{ id: '1', lat: 48.86, lng: 2.35, title: 'Louvre', mood: null, entry_date: '2025-06-01' },
{ id: '2', lat: 48.86, lng: 2.35, title: 'Sacré-Cœur', mood: null, entry_date: '2025-06-01' },
{ id: '3', lat: 52.52, lng: 13.4, title: 'Museumsinsel', mood: null, entry_date: '2025-06-02' },
])
})
it('FE-COMP-JMAPVIEW-002: the stats row counts places, days and stories', () => {
renderMapView()
const places = screen.getByText('Places').previousElementSibling
const days = screen.getByText('Days').previousElementSibling
const stories = screen.getByText('Stories').previousElementSibling
expect(places).toHaveTextContent('3')
expect(days).toHaveTextContent('2')
// the check-in is not a story
expect(stories).toHaveTextContent('3')
})
it('FE-COMP-JMAPVIEW-003: a journey with no located entries hides the stats row', () => {
renderMapView({ mapEntries: [] })
expect(screen.queryByText('Places')).not.toBeInTheDocument()
expect(screen.queryByText('Day 1')).not.toBeInTheDocument()
})
it('FE-COMP-JMAPVIEW-004: entries are grouped under numbered day headers', () => {
renderMapView()
expect(screen.getByText('Day 1')).toBeInTheDocument()
expect(screen.getByText('Day 2')).toBeInTheDocument()
expect(screen.getByText('June 1')).toBeInTheDocument()
expect(screen.getByText('June 2')).toBeInTheDocument()
})
it('FE-COMP-JMAPVIEW-005: each row is numbered by its position across the whole journey', () => {
renderMapView()
const berlin = screen.getByText('Museumsinsel').closest('div[class*="cursor-pointer"]')!
expect(within(berlin as HTMLElement).getByText('3')).toBeInTheDocument()
})
it('FE-COMP-JMAPVIEW-006: the location line shortens the place and appends the time', () => {
renderMapView()
expect(screen.getByText('Paris, France · 09:00')).toBeInTheDocument()
expect(screen.getByText('Montmartre, Paris, France')).toBeInTheDocument()
})
it('FE-COMP-JMAPVIEW-007: an entry without a title falls back to its location name', () => {
renderMapView({
mapEntries: [buildEntry({ id: 9, title: null, location_name: 'Reykjavík' })],
})
expect(screen.getAllByText('Reykjavík').length).toBeGreaterThan(0)
})
it('FE-COMP-JMAPVIEW-008: clicking a row reports the entry id', () => {
const onLocationClick = vi.fn()
renderMapView({ onLocationClick })
fireEvent.click(screen.getByText('Sacré-Cœur'))
expect(onLocationClick).toHaveBeenCalledWith('2')
})
it('FE-COMP-JMAPVIEW-009: the map reports marker clicks through the same handler', () => {
const onLocationClick = vi.fn()
renderMapView({ onLocationClick })
expect(lastMapProps.current.onMarkerClick).toBe(onLocationClick)
})
it('FE-COMP-JMAPVIEW-010: the active row is outlined and forwarded to the map', () => {
renderMapView({ activeLocationId: '2' })
expect(lastMapProps.current.activeMarkerId).toBe('2')
const row = screen.getByText('Sacré-Cœur').closest('div[class*="cursor-pointer"]') as HTMLElement
expect(row.className).toContain('border-zinc-900')
const inactive = screen.getByText('Louvre').closest('div[class*="cursor-pointer"]') as HTMLElement
expect(inactive.className).toContain('border-zinc-200')
})
it('FE-COMP-JMAPVIEW-011: connectors sit between same-day rows, never after the last one', () => {
const { container } = renderMapView()
// day 1 has two rows (one connector), day 2 has a single row (none)
expect(container.querySelectorAll('.w-0\\.5')).toHaveLength(1)
})
})

Some files were not shown because too many files have changed in this diff Show More