Files
TREK/server/tests/unit/nest/trips.service.test.ts
T
jubnl 305b113d5f Feat/storage driver (#1949)
* feat(storage): storage key validation, driver contract suite, LocalDriver

* feat(storage): MirrorDriver composite backend

* feat(server): add TREK_UPLOADS_DIR env var for the uploads root

* feat(storage): settings-backed registry, StorageService facade, module wiring

* test(storage): pin src/nest/storage coverage thresholds

* feat(storage): multer upload options factory

* refactor(files): spool trip-file uploads through storage

* refactor(collab): spool note attachments through storage; normalize files/ prefix

* refactor(journey): spool journey media uploads through storage

* refactor(trips): spool trip cover upload through storage

* refactor(places): spool place-image and collection uploads through storage

* refactor(auth): spool avatar upload through storage

* fix(server): consolidate allowed_file_types onto AllowedFileTypesService

* fix(storage): default defParamCharset to utf8 in the upload factory

* test(db): re-point the trailing-migration guard at the trip_files prefix strip

* fix(storage): age-gate the boot spool sweep

* test(files): key FILE-P02 residue checks on upload size

* test(storage): raise src/nest/storage coverage pin

* refactor(platform): serve /uploads routes through storage

* refactor(files): serve trip-file downloads through storage

* refactor(memories): serve photo bytes through storage

* refactor(maps): stream place-photo bytes through storage

* refactor(share): stream shared place-photo bytes through storage

* refactor(journey): serve public journey assets through storage

* fix(share): answer place-photo cache miss with 204 like the maps proxy

* test(server): close serving-swap gaps surfaced by the full tier

* test(storage): raise src/nest/storage branches pin

* refactor(place-photos): route place-photo cache through storage

* refactor(places): reclaim place images and collection covers through storage

* refactor(trips): trip covers through storage

* refactor(auth): avatar reclaim through storage

* refactor(collab): note-file deletion through storage

* refactor(files): trash deletion through storage

* refactor(memories): trek photo cache internals through storage

* refactor(memories): thumbnail service category-addressed

* refactor(memories): immich upload reads through storage; drop uploads-root

* refactor(journey): photo deletion through storage

* fix(memories): reclaim orphaned trek photo cache objects in the ttl sweep

* feat(journey): journey-thumbs orphan sweep and delete-site derived-artifact reclaim

* test(server): close slice-4 gaps surfaced by the full tier

* test(coverage): raise slice-4 domain pins

* refactor(backup): list, existence and delete through storage

* refactor(backup): download through storage sendToResponse

* refactor(backup): create backup through per-category storage enumeration

* refactor(backup): restore and upload-restore through storage

* refactor(backup): auto-backup retention through storage

* refactor(server): boot dir-creation to driver init

* test(server): close slice-5 gaps surfaced by the coverage tier

* test(coverage): raise slice-5 backup pin

* test(server): resolve async backupFileExists mocks for the tests typecheck

* test(server): pin ranged-read stat and zero-byte roundtrip in the storage driver contract

* feat(server): TREK_S3_* env wiring — schema, s3 namespace, all-or-nothing boot rule

* feat(server): S3 driver core — aws-lite client seam, stat/delete, driver-owned deadlines

* refactor(server): type the aws-lite S3 client via s3-types reference

* feat(server): S3 driver getStream and list — ranged full-object stat, iterator pagination

* feat(server): S3 driver put — bounded-peek routing, Upload inactivity deadline

* fix(server): S3 driver Upload monitoring — Transform interposition, stream cleanup

* feat(server): env-declared s3-main storage backend; settings-declared s3 rejected

* test(server): env-gated S3 driver contract suite against MinIO

* fix(server): S3 driver zero-byte put — work around aws4 duplicate Content-Length signing defect

* fix(server): scope the zero-byte put workaround to buffered bodies; bind test MinIO to localhost

* ci(server): MinIO-backed S3 driver contract job

* ci(server): align s3-contract shim comment and MinIO bind with references

* docs: S3 storage backend configuration and off-box backup mirroring

* docs: restore README formatting, keep S3 additions only

* ci(server): make live-MinIO contract runs manual-only (AIStor licensing)

MinIO's community line is discontinued entirely; the maintained successor, AIStor, requires a license key that can't safely live in a shared CI runner. The s3-contract CI job is removed; live-provider contract runs against MinIO/AIStor are now a manual, license-gated procedure documented in docker-compose.minio-test.yml's header.

* chore(server): final-review polish — stale CI refs, dockerignore key, boundary pins, zero-byte tempfile contract case

* expose admin ui

* feat(shared): storage backend type registry, wire schemas, category relocation

* feat(server): accept settings-declared s3 backends — shared-schema options, decrypt-at-build, registry preview()

* feat(server): remove TREK_S3_*/TREK_UPLOADS_DIR — tombstone boot rule, built-in override as the relocation path

* feat(server): deriveSecurity.encryptionKeySet and storage secret plumbing (gate, encrypt, unmask, mask, redact)

* chore(server): drop storage-secrets import that only Task 8's probe helper will use

* feat(server): seed-once storage-config.json boot import — fail closed on any invalid seed

* feat(server): registry snapshot() — effective storage config with provenance

* feat(server): api/admin/storage GET/PUT — masked secrets, preview-gated saves, wired reload, audits

* feat(server): api/admin/storage/test — per-target ephemeral probes, mirror targets probed individually

* test(server): e2e coverage for api/admin/storage — guards, managed refusal, envelope, probes

* test(server): give e2e harness DBs the app_settings table the storage registry reads at boot

* test(coverage): raise pins after the storage admin-config slice

* fix(server): final-review polish — encryption-gate wording, all-field sentinel guard, README/e2e notes

* test(server): storage slice-1 review follow-ups — mirror s3-replica probe decrypt, all-encrypted seed without key, test-route 401

* feat(i18n): storage admin domain + tab label in every locale

* feat(client): adminApi storage endpoints — getStorage, updateStorage, testStorageBackend

* feat(client): storage admin draft-document helpers + relative-time util

* feat(client): schema-driven storage BackendForm — fields by kind, mask echo, encryption banner

* feat(client): admin storage panel — backends list, category map, health strip, one-PUT save

* feat(client): storage tab on the admin page

* feat(client): storage admin panel and section on the phone shell

* fix(client): block renaming a storage backend onto an existing name

* feat(server): allow mirrors on any storage category — lift the backups-only rule

* feat(i18n): replicas-on-primary mirror strings in every locale

* feat(client): mirror fold/synthesize model helpers for replicas-on-primary

* feat(client): BackendForm mirror-targets composer; raw mirror type hidden

* feat(client): desktop storage panel folds mirrors into their primaries

* feat(client): phone storage panel folds mirrors into their primaries

* fix(client): strip removed backends from remaining mirror replicas — no phantom refs

* feat(storage): retire photos as a configurable category — served-only via the ServedCategory seam

* feat(i18n): descriptive storage category names and descriptions in every locale

* feat(client): descriptive category names, descriptions and id badges in the storage panel

* docs(storage): fix stale nine-category comments after the eight-category shrink

* docs: storage configuration via the admin UI and seed file — README + compose

* docs(wiki): Admin Storage page and storage cross-links

* docs(server): storage domain blueprint entry; TREK_PLACE_PHOTO_DIR tombstone clarifier

* refactor(server): drop the storage env-var tombstones — the variables never shipped

* docs: storage config is simply new in v4 — drop the removed-variables story

* feat(shared): backfill-status and usage wire schemas on the storage admin state

* feat(server): MirrorDriver.backfill — prefix-scoped, size-compared, cancellable replica catch-up

* feat(server): one-at-a-time storage backfill jobs + the storage events bridge

* feat(server): storage usage scan — nightly cron, on-demand service, persisted with computedAt

* feat(server): replica_failure admin notifications — events bridge, hourly debounce, channel registration

* feat(i18n): replica-failure notification text in every locale

* feat(server): storage backfill and stats-refresh admin endpoints

* feat(client): adminApi backfill and stats-refresh endpoints

* feat(i18n): storage sync and usage strings in every locale

* feat(client): desktop storage panel — sync now, usage display, backfill polling

* feat(client): phone storage panel — sync now, usage display, backfill polling

* fix(client): per-category usage line on the phone storage panel

* docs: sync now, usage stats and replica-failure notifications

* test(server): admin scope now includes replica_failure, not just version_available

Commit 6db135da added replica_failure to ADMIN_SCOPED_EVENTS but left
NPREF-005b asserting the admin scope contained only version_available.
Update the expectation to match.

* test(server): cover storage backfill/stats/jobs branches to hold the coverage pin

* fix(client): guard storage admin polling against stale responses racing a save

A poll GET issued before a save's PUT resolves could land after the save
applied its fresher response, overwriting it with the pre-save world and
re-deriving the draft from that stale snapshot. useStorageAdmin now tracks
a state-application sequence number and drops any refreshState() response
whose sequence has moved on, or that resolves while a save is in flight.
Also guard the fire-and-forget refreshState() calls in startBackfill/
cancelBackfill against unhandled rejections, matching the poll's style.

* fix(server): report 404 before 409 when a backfill start names an unknown mirror

startBackfill checked the global busy flag before validating that :name is
a mirror routed by some category, so POST .../backends/garbage/backfill
answered 409 instead of 404 whenever a sync was already running. Resolve
and validate the target first (BackfillTargetError -> 404), then check
busy (BackfillBusyError -> 409).

* feat(client): two-column category grid on the storage panel

* feat(server): plugin files.getContent reads through the storage layer

* feat(server): plugin files.create writes through the storage layer

* refactor(server): drop FilesService.resolveFilePath, last direct-fs seam in the plugin surface

* fix(server): map StorageInvalidKeyError to the plugin file accessibility envelope

* feat(storage): allow credentialed backends without an explicit ENCRYPTION_KEY

Secrets are still always encrypted at rest; without an explicit key the
implicit data-directory key is used. Removes the plaintext-secret refusal
from the admin PUT and the seed import, the encryptionReady GET field, the
save-blocking banner in both admin shells, and the now-consumerless
security.encryptionKeySet env derivation. Docs now recommend (not require)
an explicit key, since the implicit key rides inside backup archives.

* test(client): pin the stale-poll release to the pre-save GET in FE-ADMIN-STOR-024

The stub deferred EVERY poll GET and rebound the release handle to the
newest one, so a tick landing after the save was released with the
pre-save payload — a response no real backend can produce, which the seq
guard rightly applied. Only the first (genuinely stale) GET is held now;
later GETs answer with the world the mock server currently holds. Also
drops the encryptionReady fixture field removed from the schema in the
previous commit.

* fix(storage): backfill puts carry content-type, stat failures report op stat, progress clamps

* feat(storage): category migration job — copy, flip, delta sweep, reclaimable tally

* feat(storage): migration start/cancel endpoints and client API

* test(client): storage admin fixtures carry the new migrations field

* fix(storage): migration POST answers 200 and validates its body via the shared schema

* fix(storage): cancel running storage jobs when a config save removes their backend

* fix(notifications): in-app replica-failure row carries the suppressed count

* feat(client): migration prompt, queue, and progress on the storage panel

* fix(client): migration queue holds a synchronous single-flight lock

* feat(client): migration prompt and progress on the mobile storage panel

* fix(server): register category-migration routes in the managed-route allow list

StorageAdminController.migrationStart/migrationCancel were added under the
controller-level @ManagedForbidden() decorator but never added to
MANAGED_ROUTE_ALLOW_LIST, so validateManagedRoutes() threw "Managed surface
changed" on every buildApp() call, failing 46 test files (any test booting
the real app).

* test(server): cover category-migration copy/sweep branches to hold the storage coverage pin

Adds MIG-005 (a target object already matching size/key is skipped in the
copy phase itself, not just the delta sweep) and MIG-006 (a copy failure
during the delta sweep is counted without undoing the flip that already
happened). src/nest/storage/**'s branch coverage was 83.91%, just under the
84% pin.

* docs(storage): category migration — move existing objects on reassign

* fix(storage): close the pre-flip cancel window and cancel re-routed migrations

A cancel landing between the last copy iteration and the category flip was
silently ignored, and a config save that re-routed the migrating category to
a third, still-defined backend never stopped the in-flight migration. Also
guards assignCategory against writing an unknown backend name, and fixes a
missing `suppressed` notification param being treated as truthy suppression.

* fix(client): migrations keep mirrors, queue waits on backfills, sweep failures surface

Reassigning a category onto a mirrored primary now posts the migration to the
mirror's wire name instead of the bare primary, so replication survives the
move. The queued-migration effect also waits out a running backfill (the
server's one-job rule spans both), and a done migration with sweep failures
now surfaces a doneFailures line in both shells, translated into every locale.

* ci(server): run the S3 contract suite against MinIO

Resurrects the s3-contract job removed in 6fbff835, adapted for AIStor:
writes the MINIO_LICENSE_KEY secret to a license file, boots the licensed
container, and runs the contract suite hermetically (no --coverage) so the
storage coverage pin keeps holding from unit tests alone. Gated to same-repo
PRs since fork PRs have no access to the license secret.

* fix(server): backup archives uploads from a remote primary

createBackup() enumerated uploads through withLocalFile, which downloads a
remote object to a temp file, hands it to the callback, then deletes it
before archiver ever reads it — archiver reads entries lazily during
finalize(), so a remote (S3) primary silently backed up zero upload bytes.

Add StorageService.getLocalPathOrNull(category, name): a narrow locality
probe that returns a real path only when one exists on disk right now (the
fs-existence check is the fail-safe half of the contract — a driver-reported
path that vanished between list() and here falls back too, not just a
path-less remote driver). The enumeration loop uses it directly when
available (zero-copy, unchanged for a local primary) and otherwise streams
the object into a per-backup staging dir under the backups spool, cleaned up
in the existing finally alongside zipSpool/dbSnap.

Also wire archive.on('warning', reject): archiver treats a missing/unreadable
entry as a warning, not an error, and skips it by default — which would
silently drop the entry from an otherwise "successful" backup.

* fix(server): client aborts stop 500ing and leaking streams

storage.service.ts sendToResponse: the local res.sendFile branch now treats
ECONNABORTED/ECONNRESET/ERR_STREAM_PREMATURE_CLOSE/syscall=write as success,
mirroring res.sendFile's own default callback (the contract
storageStaticHandler already implements caller-side). The remote branch
replaces the hand-rolled pipe+Promise with pipeline(stream, res), which
destroys the source stream on any failure — fixing the leaked S3 keep-alive
socket on a client abort — while swallowing the same client-abort error set;
a real source error still rejects so callers keep their miss contracts.

This centralizes the fix for every caller that streams through
sendToResponse: files-download, journey-public, photo-resolver,
trek-photo-cache, backup downloads, and /uploads/photos.

trek-exception.filter.ts now no-ops (destroying the socket) when headers are
already sent, so no filter path ever writes onto a response in flight.

maps.controller.ts and share.controller.ts's place-photo bytes proxies
switch their direct getStream+pipe to pipeline() with the same abort-tolerant
catch, keeping the existing 204-empty fallback for pre-header failures.

* fix(storage): mirror read fallback consults replicas on primary miss

MirrorDriver.getStream/stat now fall back to replicas on a primary miss
(StorageNotFoundError / null stat), not just an infra error — binding
decision: a read succeeds if ANY member holds the object. Only a
deterministic StorageInvalidKeyError skips fallback.

stat() gets a dedicated fallback loop instead of reusing fromReplicas:
a replica returning null means "keep looking," which the generic
catch-and-continue helper would have short-circuited on.

StorageService.sendToResponse/withLocalFile stop treating composite
driver.stat() as proof the object is at localPath; both now fs-check
the path itself and fall through to the stream branch when it's
missing, which is what actually reaches a MirrorDriver's replicas.

* feat(storage): remote serving parity — content-type, validators, range

Local backends get content type, ETag/Last-Modified, conditional GET, Range
and HEAD for free from res.sendFile; the proxying branch every remote
backend (and a local primary whose file vanished) falls through to had none
of it — every request re-streamed the whole object with no cache validator
and no way to seek.

sendStreamed() reproduces the contract from ONE pre-flight driver.stat():
a miss throws before any header is set (the caller's 404/204/next()
contract), and that same stat then answers 304s, 416s, HEADs and suffix
ranges without ever opening a body. The decisions live in a new pure module,
http-serving.ts:

- validatorsFor: the driver's ETag, else express/send's weak
  W/"<size hex>-<mtime hex>". mtimeMs 0 (S3 can report it) means no
  Last-Modified and no derived tag — serving uncached beats a 1970 header
  that reads as fresh forever.
- isNotModified: If-None-Match (weak compare, *, list) wins over
  If-Modified-Since, per RFC 9110 §13.2.2.
- decideRange: single range only, inclusive clamp, suffix computed from the
  pre-stat size, unsatisfiable → 416, If-Range strong-exact or full entity.
  A multi-range or malformed spec is answered with the full object rather
  than multipart/byteranges. If-Match/If-Unmodified-Since stay out of scope.

Range math uses the pre-stat size as the single source of truth, the ranged
getStream stat is never re-consulted, and the pipeline abort semantics are
unchanged.

S3Driver now surfaces the bucket's ETag on stat()/getStream() (on a ranged
GET it is still the full object's tag). aws-lite strips the tag's
surrounding quotes — verified live against AIStor RELEASE.2026-08-07 — and
an unquoted entity-tag is not a valid HTTP ETag, so the driver restores
exactly the quotes the SDK removed and nothing else.

* fix(server): key rotation covers storage.backends

The S3 storage backend's secretAccessKey is encrypted at rest inside the
app_settings row keyed storage.backends, but scripts/migrate-encryption.ts
didn't know about it — after a key rotation that secret stayed encrypted
under the old key and silently failed to decrypt on the next read.

The script now re-encrypts secretAccessKey for every s3 backend inside the
storage.backends JSON array, using a pinned copy of the secret-field list
(it deliberately can't import @trek/shared). The parity test pins that copy
against storageSecretFields('s3') so a new secret field can't be missed
silently.

* fix(server): restore reloads the storage registry

restoreFromZip closed and reopened the DB but never reloaded the
storage registry, so uploads rehydration ran against the STALE
pre-restore driver/category map instead of the config the restored
archive's own travel.db carries (audit #4, major).

StorageService gains reloadConfig(), a narrow passthrough to the
(unexported) registry's reload() — same shape as health(). backup.impl's
restoreFromZip now calls it right after reinitialize() and right before
the uploads wipe/rehydrateUploads/plugin staging, so rehydrated bytes
land where the restored config says. When reopen fails (reinitFailed),
reload and rehydration are skipped entirely — already reported as
"restart required", and there is no live DB handle for the registry to
read anyway.

* fix(server): category migration rewrites keys across prefix changes

Category migration copied objects to the destination backend using the
SOURCE key verbatim. A mode-A ('' prefix) source migrating onto a
prefixed backend (or vice versa) landed objects at keys the registry
could never resolve post-flip (audit #8).

Extracts StorageRegistryService.keyPrefixFor(category, backendName) from
build()'s inline conditional so startMigration can compute the
destination prefix before the flip. runMigration now maps every
destination key (destPrefix + key.slice(sourcePrefix.length)) in the
copy skip-check, the put, and the delta sweep; the reclaimable tally
stays source-keyed. Equal-prefix migrations are unaffected (identity
mapping).

* fix(plugins): await the now-async place/collection services in RPCs

PlacesService.update/remove and CollectionsService.deletePlace became
async when they started deleting storage objects, but their plugin RPC
handlers still called them synchronously: the null/false refusal check
compared against an always-truthy Promise, and places.update broadcast
a Promise instead of the resolved place. Both handlers now await the
service call; mapCollectionError is async-aware (accepts a sync-or-
async thunk) so the sync callers (create/update/savePlace/copyToTrip)
keep working through the same path. Verified the RPC host already
awaits handler results, so no change was needed there.

* fix(storage): backfill Sync now gains a deletion sweep

The Sync now backfill copied primary objects to replicas but never removed
replica objects the primary no longer has, leaving mirror read-fallback able
to serve stale data indefinitely after a failed replica delete. backfill()
now sweeps each replica post-copy: any replica key absent from the primary
key-set is re-checked against primary.stat (a race gate against the primary
regaining the key) before being deleted. Adds a deleted counter end-to-end
(driver, jobs service, shared schema, both admin shells, all 23 locales) and
a new ReplicaFailure 'list' op for a replica whose listing itself fails
during the sweep.

* feat(storage): optimistic concurrency for admin config saves

A stale admin PUT of storage settings could silently undo a category
migration's just-flipped assignment. Adds a version counter
(app_settings row storage.config_version) bumped inside the same
transaction by both writers — applyConfig and the migration flip
(assignCategory). applyConfig compares the submitted version first
and throws StorageConflictError, mapped to 409 ahead of the blanket
400. The client carries the draft's loaded version through every
local edit and maps a 409 to a distinct "settings changed underneath
you" message, refreshing state while preserving the dirty draft for
review.

* fix(storage): surface a failed config load + redact secretAccessKey

Registry load() now records the exact parse/validate failure as
lastLoadError() instead of silently falling back to last-good config or
built-in defaults; StorageAdminService exposes it as configError on the
admin state (storageAdminStateSchema extended additively), and
AdminStoragePanel renders it as a warning banner naming the error. Save
stays enabled — it's the recovery path — but an admin can no longer save
over a broken stored config without knowing.

Also adds 'secretaccesskey' to globalMiddleware's SENSITIVE_KEYS request-log
redaction list, so storage admin PUT bodies never leak the S3 secret into
debug logs.

* fix(client): admin storage panel state races

Migration prompt now recomputes computeMigrationCandidates(draft, state)
on render and again at confirm time, so a stale open-time snapshot never
drives stripCategories or the migration queue; adds a Cancel button to
the dialog. A failed startMigration POST explicitly clears the remaining
queue with a toast naming the dropped categories and refreshes state,
instead of leaving it invisibly stranded; a "Queued: X, Y" line renders
while the queue is non-empty. Testing a mirrored row now expands the
draft primary + replicas client-side and probes each individually,
instead of sending the mirror stub to the server (which resolved
targets from its own live snapshot, silently testing saved options over
unsaved draft edits). A completed/cancelled/errored backfill now renders
its terminal status alongside the Sync now button instead of hiding it,
so it can be re-run without a page reload.

* fix(storage): refuse shared replica backends so the sync sweep cannot cross categories

A mirror's "Sync now" sweep makes each replica MATCH the primary: it lists the
replica under every prefix the mirror's categories occupy and deletes the keys
the primary doesn't hold. The backups category's prefix is '' — the replica's
entire root — so making a category-serving backend (say uploads-local) a
replica of a backups mirror meant the first sync deleted every user upload.
Nothing forbade the config: validateConfig only checked that mirror targets
existed and weren't nested, and the admin picker offered every backend.

The sweep is correct, so the fix is at the config level:

- validateConfig refuses a backend that is both a mirror replica and a
  category target (directly, or as the primary of a mirror a category routes
  to), and refuses a backend replicating two mirrors whose swept prefixes
  overlap — '' overlaps everything. Disjoint prefixes stay legal.
- assignCategory (the migration flip, the other writer of the settings rows)
  refuses routing a category onto a backend that is currently a replica.
- The admin mirror-target picker (both shells) only offers backends that serve
  no category; already-selected targets stay listed so they can be unchecked.
  A category assigned to a replica still surfaces the server's 400 verbatim.

Existing shared-replica fixtures were rewritten onto dedicated replicas.

* fix(client): a 409 conflict save is recoverable without a page reload

After a 409 the admin storage panel was a dead end. refreshState leaves a
dirty draft's version alone on purpose (audit #7: the background poll must
never float a mid-edit draft past a migration flip it never carried), setDraft
re-attaches that same version to every later edit, and save() resubmits it —
so every retry 409s again and only a full page reload got the operator out,
while the banner told them to "review and save again".

The version block stays. Instead the conflict banner now offers the one action
that can clear it: discard the local edits and reload the saved settings —
a fresh GET applied through applyState, re-deriving the draft (and its
version) and clearing dirty/saveError. Bumping the version in refreshState,
or only in the 409 branch, was rejected: either resurrects audit #7, since a
full-document save would then revert a flip the draft never carried.

storage.saveConflict now points at that action instead of implying a plain
re-save works (all 23 locales). The escape lives in the shared hook, so the
phone panel renders it too.

* fix(client): deflake the queued-migration-waits-out-backfill panel test

FE-ADMIN-STOR-037 seeds a running backfill, so the panel polls GET from
first render — but the stub served the post-save world (files: off-box)
before the PUT had landed, which no real server would do. A poll
resolving while the confirm dialog was open made the confirm-time
candidate recompute see from === to, so nothing was queued and the
migration never POSTed.

The GET stub now stays faithful (pre-save world until the PUT lands,
usage included so candidates keep their object counts), and the test
deterministically forces the once-flaky interleaving: it waits for
polls to land while the dialog is open and asserts the candidate
survives them.

* fix(storage): surface the underlying cause in probe target errors

The admin storage Test probe reported only the driver wrapper's outer
message ("put failed for '…' on 'probe-s3'"), dropping the cause that
says WHY — connection refused vs missing bucket. Flatten the error's
cause chain into the probe result, appending an S3-style code when it
isn't already in the message.

* fix(storage): surface the underlying cause in replica-failure reports

Same defect as the probe fix, one layer over: MirrorDriver's
reportReplicaFailure captured only the wrapper's message, so
replica-failure notifications said "put failed" without the why.
Move the probe's cause-chain flattener into storage.types.ts as
describeError and use it in both call sites.

* fix(storage): strip ContentType from multipart Upload calls (MalformedXML)

Every multipart S3 put carrying a contentType — every backup zip and
photo over the 10MB threshold — failed with 400 MalformedXML. Upstream
@aws-lite/s3's Upload leaks the ContentType param into its final
CompleteMultipartUpload headers ({ ...xml, ...paramHeaders }), clobbering
application/xml, which flips @aws-lite/client's payload serializer to
JSON — so the server receives a JSON parts list where XML belongs.
Confirmed live against MinIO AIStor; any S3 refuses the same.

uploadMonitored now drops ContentType before s3.Upload. Multipart
objects are stored without a content-type, which TREK never reads back
(serving derives Content-Type from the key/caller, never object
metadata); single-request PutObject paths keep sending it.

Also closes the coverage gap that let this ship: the live contract
suite never crossed MULTIPART_THRESHOLD, so s3.Upload had never run
against a real server — it now round-trips a >10MB put carrying a
contentType.

---------

Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-08-22 11:33:55 +02:00

1126 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Unit tests for the DI-native TripsService — TRIP-SVC-001 through TRIP-SVC-059
* (001–038 moved 1:1 from the legacy tests/unit/services/tripService.test.ts;
* the exportICS cases that duplicated the generateDays 010–012 numbering were
* renumbered to 024–026 with the post-fold quirk-fix commit; 040–041 pinned
* the deleted trips.bridge and died with it; 042–050 cover the folded
* summary/list/create/delete/copy SQL; 051–053 pin the post-fold quirk fixes
* (transactional deletes, owner display_name)). Uses a real in-memory SQLite
* DB so SQL logic is exercised faithfully.
*
* The membership and read-aggregate cases now drive TripMembersService and
* TripReadModelService, which is where that code went when the aggregate root
* was split. They stayed in this file, over the same in-memory DB, so the diff
* shows the move rather than a rewrite.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
db.exec('PRAGMA busy_timeout = 5000');
const mock = {
db,
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
SELECT t.id, t.user_id FROM trips t
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
`).get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast }));
// notifyInvite fires a notification via a dynamic import — keep it out of unit scope
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote, addTripMember } from '../../helpers/factories';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { DaysService } from '../../../src/nest/days/days.service';
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
import { TodoService } from '../../../src/nest/todo/todo.service';
import { PackingService } from '../../../src/nest/packing/packing.service';
import { FilesService } from '../../../src/nest/files/files.service';
import { ReservationsService } from '../../../src/nest/reservations/reservations.service';
import { ReservationsReadRepository } from '../../../src/nest/reservations/reservations-read.repository';
import { BudgetService } from '../../../src/nest/budget/budget.service';
import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service';
import { CollabService } from '../../../src/nest/collab/collab.service';
import { VacayService } from '../../../src/nest/vacay/vacay.service';
import { TripsService } from '../../../src/nest/trips/trips.service';
import { PlacesService } from '../../../src/nest/places/places.service';
import { UserCleanupService } from '../../../src/nest/auth/user-cleanup.service';
import { TripMembersService } from '../../../src/nest/trip-members/trip-members.service';
import { TripReadModelService } from '../../../src/nest/trip-read-model/trip-read-model.service';
import { AccommodationsService } from '../../../src/nest/accommodations/accommodations.service';
import { MapsService } from '../../../src/nest/maps/maps.service';
import { UnsplashService } from '../../../src/nest/unsplash/unsplash.service';
import { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
import { JourneyDomainService } from '../../../src/nest/journey/journey-domain.service';
import { TrekPhotosRepository } from '../../../src/nest/photos/trek-photos.repository';
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { makeStorageFixture } from '../../helpers/storage-fixture';
import { QueryHelpersService } from '../../../src/nest/query-helpers/query-helpers.service';
import fs from 'fs';
import path from 'path';
import { notificationsStub } from '../../helpers/notifications';
import { EphemeralTokenService } from '../../../src/nest/auth/ephemeral-token.service';
// Real sibling services over the same in-memory DB — updateTrip's date-shift
// resyncs and the summary/bundle aggregation run their actual SQL.
const dbs = () => new DatabaseService(testDb);
const budgetSvc = new BudgetService(dbs(), new PermissionsService(dbs()), new ExchangeRatesService(), new RealtimeService());
const daysSvc = new DaysService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new QueryHelpersService(dbs()));
// Same collaborator set the container hands PlacesService (see places.service.test.ts).
// Only the read-model aggregation reaches into places here, but the photo cache,
// Unsplash and journey domain are real instances over the same in-memory DB
// rather than casts: the place hooks are fire-and-forget behind a catch, so a
// missing collaborator would look like a pass while swallowing a TypeError.
// One PlacePhotoCacheService for both PlacesService and MapsService, matching
// production, where the in-flight dedup only works on a shared instance.
const photoCache = new PlacePhotoCacheService(dbs(), makeStorageFixture('photos/google/').storage);
const coversFx = makeStorageFixture('covers/');
const placesSvc = new PlacesService(
dbs(),
new PermissionsService(dbs()),
new RealtimeService(),
new MapsService(dbs(), photoCache),
new QueryHelpersService(dbs()),
new UnsplashService(dbs(), new RuntimeEnvService(), coversFx.storage),
photoCache,
new JourneyDomainService(dbs(), new RealtimeService(), new TrekPhotosRepository(dbs())),
makeStorageFixture('').storage,
);
const accommodationsSvc = new AccommodationsService(dbs(), new PermissionsService(dbs()), new RealtimeService());
const createAccommodation = accommodationsSvc.createAccommodation.bind(accommodationsSvc);
const svc = new TripsService(
dbs(),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs())),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new RealtimeService(),
undefined as never, // unsplash — not exercised here
coversFx.storage,
);
const membersSvc = new TripMembersService(dbs(), budgetSvc, new UserCleanupService(dbs(), budgetSvc), new PermissionsService(dbs()), new RealtimeService(), notificationsStub());
const readModelSvc = new TripReadModelService(
dbs(), membersSvc, daysSvc, accommodationsSvc, budgetSvc,
new PackingService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub()),
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs())),
new CollabService(dbs(), new PermissionsService(dbs()), new RealtimeService(), notificationsStub(), coversFx.storage),
placesSvc,
new TodoService(dbs(), new PermissionsService(dbs()), new RealtimeService()),
new FilesService(dbs(), new PermissionsService(dbs()), new RealtimeService(), new EphemeralTokenService(), coversFx.storage),
);
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
});
beforeEach(() => {
resetTestDb(testDb);
});
afterAll(() => {
testDb.close();
});
// ── Helpers ───────────────────────────────────────────────────────────────────
function getDays(tripId: number) {
return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number; trip_id: number; day_number: number; date: string | null;
}[];
}
function getAssignments(dayId: number) {
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
function getNotes(dayId: number) {
return testDb.prepare('SELECT * FROM day_notes WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('generateDays', () => {
it('TRIP-SVC-010: full range shift preserves day assignments and notes positionally', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
const note = createDayNote(testDb, daysBefore[1].id, trip.id, { text: 'packed' });
// Shift forward 9 days — zero overlap with original dates
svc.generateDays(trip.id, '2025-06-10', '2025-06-14');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14',
]);
// day_number 1 (formerly June 1) now has date June 10 — assignment still attached
const day1 = daysAfter[0];
const day2 = daysAfter[1];
expect(getAssignments(day1.id)).toHaveLength(1);
expect(getAssignments(day1.id)[0].id).toBe(assignment.id);
expect(getNotes(day2.id)).toHaveLength(1);
expect(getNotes(day2.id)[0].id).toBe(note.id);
});
it('TRIP-SVC-011: shrinking range deletes overflow days and their assignments (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(5);
const place = createPlace(testDb, trip.id);
createDayAssignment(testDb, daysBefore[3].id, place.id);
createDayAssignment(testDb, daysBefore[4].id, place.id);
// Shrink from 5 to 3 days — surplus days and their content are removed
svc.generateDays(trip.id, '2025-07-01', '2025-07-03');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(3);
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
});
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-07-01', end_date: '2025-07-07' });
expect(getDays(trip.id)).toHaveLength(7);
// Shrink 7 → 5; days 6 and 7 have no content
svc.generateDays(trip.id, '2025-07-01', '2025-07-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
]);
});
it('TRIP-SVC-012: growing range keeps existing day content and appends new empty days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-08-01', end_date: '2025-08-03' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(3);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[0].id, place.id);
// Grow to 5 days
svc.generateDays(trip.id, '2025-08-01', '2025-08-05');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05',
]);
// Existing day 1 retains its assignment
expect(getAssignments(daysAfter[0].id)).toHaveLength(1);
expect(getAssignments(daysAfter[0].id)[0].id).toBe(assignment.id);
// New days 4 and 5 are empty
expect(getAssignments(daysAfter[3].id)).toHaveLength(0);
expect(getAssignments(daysAfter[4].id)).toHaveLength(0);
});
it('TRIP-SVC-013: clearing dates converts all days to dateless without destroying assignments', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-09-01', end_date: '2025-09-04' });
const daysBefore = getDays(trip.id);
expect(daysBefore).toHaveLength(4);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, daysBefore[1].id, place.id);
// Clear both dates
svc.generateDays(trip.id, null, null);
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(4);
expect(daysAfter.every(d => d.date === null)).toBe(true);
// The assignment on the former day 2 still exists
const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id);
expect(formerDay2).toBeDefined();
expect(getAssignments(formerDay2!.id)).toHaveLength(1);
expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id);
});
it('TRIP-SVC-014: partial overlap shift remaps by position (day 1→3 kept, 4-5 overflow)', () => {
// Original: Jun 1-5. New: Jun 3-7 (overlap on Jun 3-5, but we map by position)
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-10-01', end_date: '2025-10-05' });
const daysBefore = getDays(trip.id);
const place = createPlace(testDb, trip.id);
// Assign to each of the 5 days
for (const day of daysBefore) createDayAssignment(testDb, day.id, place.id);
// Shift forward 2 days (partial overlap with original range)
svc.generateDays(trip.id, '2025-10-03', '2025-10-07');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07',
]);
// All 5 assignments survive
for (const day of daysAfter) {
expect(getAssignments(day.id)).toHaveLength(1);
}
});
it('TRIP-SVC-015: growing into dateless days reuses them; leftover dateless renumber without UNIQUE collision', () => {
// 3 dated days + 2 pre-existing dateless days. Resize to 4 dated days.
// Main loop: dated[0..2] → positions 1-3, dateless[0] → position 4 (consumed).
// Unused dateless: dateless[1] should land at position 5, NOT 4 (collision bug).
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-11-01', end_date: '2025-11-03' });
// Insert 2 dateless days directly
const daysBefore = getDays(trip.id);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 4);
testDb.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(trip.id, 5);
const allDays = getDays(trip.id);
expect(allDays).toHaveLength(5);
const place = createPlace(testDb, trip.id);
// Put an assignment on the second dateless day (day_number=5) — it should survive
const assignment = createDayAssignment(testDb, allDays[4].id, place.id);
// Grow from 3 to 4 dated days — consumes dateless[0], leaves dateless[1] unused
// This is the scenario that triggered the UNIQUE collision bug
svc.generateDays(trip.id, '2025-11-01', '2025-11-04');
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
const dated = daysAfter.filter(d => d.date !== null);
const dateless = daysAfter.filter(d => d.date === null);
expect(dated).toHaveLength(4);
expect(dateless).toHaveLength(1);
// The remaining dateless day still has its assignment
expect(getAssignments(dateless[0].id)).toHaveLength(1);
expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id);
// All day_numbers are unique 1..5
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
expect(nums).toEqual([1, 2, 3, 4, 5]);
});
it('TRIP-SVC-017: switching a dateless trip to a shorter dated range drops empty leftover days but keeps ones with content (#1083)', () => {
const { user } = createUser(testDb);
// A 7-day trip, then cleared to dateless placeholders (day_count = 7).
const trip = createTrip(testDb, user.id, { start_date: '2025-12-01', end_date: '2025-12-07' });
svc.generateDays(trip.id, null, null);
const dateless = getDays(trip.id);
expect(dateless).toHaveLength(7);
expect(dateless.every(d => d.date === null)).toBe(true);
// Give the LAST dateless day real content so it must be preserved.
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, dateless[6].id, place.id);
// Now set an explicit 2-day range. The first two dateless days are reused for
// the dates; the four empty leftovers must be removed, the one with content kept.
svc.generateDays(trip.id, '2026-01-10', '2026-01-11');
const daysAfter = getDays(trip.id);
const dated = daysAfter.filter(d => d.date !== null);
const stillDateless = daysAfter.filter(d => d.date === null);
expect(dated.map(d => d.date)).toEqual(['2026-01-10', '2026-01-11']);
// day_count is COUNT(*) FROM days: 2 dated + 1 content-bearing dateless = 3 (not the stale 7)
expect(daysAfter).toHaveLength(3);
expect(stillDateless).toHaveLength(1);
expect(getAssignments(stillDateless[0].id)[0].id).toBe(assignment.id);
});
});
// ── deleteOldCover — path containment ──────────────────────────────────────────
describe('deleteOldCover', () => {
it('TRIP-SVC-COVER-001: never deletes outside the covers category for a crafted cover_image', async () => {
// Attacker-controlled values aimed at auth-gated sibling upload dirs — the
// basename + category addressing keeps every delete inside covers/.
const filesDir = path.join(coversFx.root, 'files');
const avatarsDir = path.join(coversFx.root, 'avatars');
fs.mkdirSync(filesDir, { recursive: true });
fs.mkdirSync(avatarsDir, { recursive: true });
const secret = path.join(filesDir, 'secret.pdf');
const someone = path.join(avatarsDir, 'someone.png');
fs.writeFileSync(secret, 'pdf');
fs.writeFileSync(someone, 'png');
await svc.deleteOldCover('/uploads/files/secret.pdf');
await svc.deleteOldCover('/uploads/covers/../files/secret.pdf');
await svc.deleteOldCover('/uploads/avatars/someone.png');
expect(fs.existsSync(secret)).toBe(true);
expect(fs.existsSync(someone)).toBe(true);
});
it('TRIP-SVC-COVER-002: deletes a legitimate cover file', async () => {
const coversDir = path.join(coversFx.root, 'covers');
fs.mkdirSync(coversDir, { recursive: true });
const cover = path.join(coversDir, 'abc123.jpg');
fs.writeFileSync(cover, 'jpeg');
await svc.deleteOldCover('/uploads/covers/abc123.jpg');
expect(fs.existsSync(cover)).toBe(false);
});
it('TRIP-SVC-COVER-003: an external https cover URL is tolerated (no throw)', async () => {
await expect(svc.deleteOldCover('https://example.com/some/pic.jpg')).resolves.toBeUndefined();
await expect(svc.deleteOldCover(null)).resolves.toBeUndefined();
});
});
describe('resyncReservationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertDatedReservation = (tripId: number, dayId: number, time: string) =>
Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', ?, 'restaurant', 'pending')",
).run(tripId, dayId, time).lastInsertRowid);
it('TRIP-SVC-018: changing the start date re-anchors a dated reservation to the day matching its time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = insertDatedReservation(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T19:00:00');
// Shift the whole range one day forward (days become 2025-06-02..06).
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
// The booking stays on its absolute date (2025-06-02) instead of shifting with its old day row.
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
it('TRIP-SVC-019: a reservation whose date falls outside the new range keeps its day_id (not nulled)', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = insertDatedReservation(trip.id, origDayId, '2025-06-02T19:00:00');
// Shift far forward so 2025-06-02 is no longer covered by any day.
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(origDayId);
});
});
describe('resyncAccommodationDays (#1288)', () => {
const dayFor = (tripId: number, date: string) =>
(testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(tripId, date) as { id: number }).id;
const insertAccommodation = (tripId: number, startDayId: number, endDayId: number) => {
const place = createPlace(testDb, tripId, { name: 'Grand Hotel' });
const acc = createAccommodation(tripId, {
place_id: place.id, start_day_id: startDayId, end_day_id: endDayId,
}) as { id: number };
const linkedRes = testDb.prepare(
'SELECT id FROM reservations WHERE accommodation_id = ?',
).get(acc.id) as { id: number };
return { accId: acc.id, linkedResId: linkedRes.id };
};
const getAcc = (id: number) =>
testDb.prepare('SELECT start_day_id, end_day_id FROM day_accommodations WHERE id = ?').get(id) as
{ start_day_id: number; end_day_id: number };
const getRes = (id: number) =>
testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(id) as
{ day_id: number | null; reservation_time: string | null };
it('TRIP-SVC-035: extending the start keeps an accommodation on its absolute dates', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-10', end_date: '2025-06-14' });
const { accId, linkedResId } = insertAccommodation(trip.id, dayFor(trip.id, '2025-06-11'), dayFor(trip.id, '2025-06-13'));
// Add a day at the start: days re-date positionally (old 06-11 row becomes 06-10, …).
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-09', end_date: '2025-06-14' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(dayFor(trip.id, '2025-06-11'));
expect(acc.end_day_id).toBe(dayFor(trip.id, '2025-06-13'));
const res = getRes(linkedResId);
expect(res.day_id).toBe(acc.start_day_id);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-06-11');
});
it('TRIP-SVC-036: moving the whole trip out of the old range keeps the accommodation glued to its days', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const startDayId = dayFor(trip.id, '2025-06-02');
const endDayId = dayFor(trip.id, '2025-06-03');
const { accId, linkedResId } = insertAccommodation(trip.id, startDayId, endDayId);
svc.updateTrip(trip.id, user.id, { start_date: '2025-07-01', end_date: '2025-07-05' }, 'user');
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(startDayId);
expect(acc.end_day_id).toBe(endDayId);
// The linked reservation follows the (re-dated) start day instead of keeping a stale date snapshot.
const res = getRes(linkedResId);
expect(res.day_id).toBe(startDayId);
expect(res.reservation_time?.slice(0, 10)).toBe('2025-07-02');
});
it("TRIP-SVC-038: date_shift_mode 'shift_all' glues bookings to their days and restamps their times", () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const origDayId = dayFor(trip.id, '2025-06-02');
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Dinner', '2025-06-02T19:00:00', 'restaurant', 'pending')",
).run(trip.id, origDayId).lastInsertRowid);
const { accId } = insertAccommodation(trip.id, origDayId, dayFor(trip.id, '2025-06-03'));
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-03', end_date: '2025-06-07', date_shift_mode: 'shift_all' }, 'user');
// The booking stays on its day row (now 2025-06-04) and its time follows.
const res = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resId) as
{ day_id: number; reservation_time: string };
expect(res.day_id).toBe(origDayId);
expect(res.reservation_time).toBe('2025-06-04T19:00:00');
// The accommodation stays glued to its (re-dated) day rows too.
const acc = getAcc(accId);
expect(acc.start_day_id).toBe(origDayId);
});
it('TRIP-SVC-037: a dated hotel reservation without a linked accommodation is re-anchored like other bookings', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { start_date: '2025-06-01', end_date: '2025-06-05' });
const resId = Number(testDb.prepare(
"INSERT INTO reservations (trip_id, day_id, title, reservation_time, type, status) VALUES (?, ?, 'Imported hotel', ?, 'hotel', 'pending')",
).run(trip.id, dayFor(trip.id, '2025-06-02'), '2025-06-02T15:00:00').lastInsertRowid);
svc.updateTrip(trip.id, user.id, { start_date: '2025-06-02', end_date: '2025-06-06' }, 'user');
const res = testDb.prepare('SELECT day_id FROM reservations WHERE id = ?').get(resId) as { day_id: number };
expect(res.day_id).toBe(dayFor(trip.id, '2025-06-02'));
});
});
describe('transferOwnership (#973)', () => {
it('TRIP-SVC-020: hands the trip to a member and demotes the former owner to a member', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
const result = membersSvc.transferOwnership(trip.id, member.id, owner.id);
expect(result.toEmail).toBe(member.email);
const updated = testDb.prepare('SELECT user_id FROM trips WHERE id = ?').get(trip.id) as { user_id: number };
expect(updated.user_id).toBe(member.id);
// New owner no longer sits in trip_members, former owner now does.
const memberIds = (testDb.prepare('SELECT user_id FROM trip_members WHERE trip_id = ?').all(trip.id) as { user_id: number }[]).map(r => r.user_id);
expect(memberIds).toContain(owner.id);
expect(memberIds).not.toContain(member.id);
});
it('TRIP-SVC-021: rejects a transfer from a non-owner', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
addTripMember(testDb, trip.id, member.id);
// member (not the owner) attempts the transfer
expect(() => membersSvc.transferOwnership(trip.id, member.id, member.id)).toThrow();
});
it('TRIP-SVC-022: rejects a transfer to someone who is not a member', () => {
const { user: owner } = createUser(testDb);
const { user: stranger } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => membersSvc.transferOwnership(trip.id, stranger.id, owner.id)).toThrow('New owner must be a trip member');
});
it('TRIP-SVC-023: rejects transferring to yourself', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
expect(() => membersSvc.transferOwnership(trip.id, owner.id, owner.id)).toThrow('You already own this trip');
});
});
describe('guest members (#1362)', () => {
it('TRIP-SVC-030: createGuest adds a credential-less user joined into the trip', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, ' Anna ', owner.id);
expect(member.username).toBe('Anna');
expect(member.is_guest).toBe(true);
const row = testDb.prepare('SELECT username, email, password_hash, is_guest, role FROM users WHERE id = ?').get(member.id) as any;
expect(row.is_guest).toBe(1);
expect(row.password_hash).toBe('');
expect(row.email).toMatch(/@guests\.invalid$/);
expect(row.role).toBe('user');
// Joined as a trip member.
const m = testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
expect(m).toBeTruthy();
// Surfaces in listMembers with is_guest=true and the typed display name.
const { members } = membersSvc.listMembers(trip.id, owner.id) as any;
const guest = members.find((x: any) => x.id === member.id);
expect(guest.username).toBe('Anna');
expect(guest.is_guest).toBe(true);
});
it('TRIP-SVC-031: the same guest name is allowed, not suffixed (#1446)', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const a = membersSvc.createGuest(trip.id, 'Sam', owner.id);
const b = membersSvc.createGuest(trip.id, 'Sam', owner.id);
// both keep the plain display name; only the internal (uuid) username differs
expect(a.member.username).toBe('Sam');
expect(b.member.username).toBe('Sam');
expect(b.member.id).not.toBe(a.member.id);
const usernames = testDb.prepare('SELECT username FROM users WHERE id IN (?, ?)').all(a.member.id, b.member.id) as { username: string }[];
expect(usernames[0].username).not.toBe(usernames[1].username);
});
it('TRIP-SVC-032: renameGuest updates the display name (trip-scoped, guest-only)', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const otherTrip = createTrip(testDb, other.id);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Bob', owner.id);
expect(membersSvc.renameGuest(trip.id, member.id, 'Robert')).toBe(true);
expect((testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(member.id) as any).display_name).toBe('Robert');
// A real user cannot be renamed through the guest path…
expect(membersSvc.renameGuest(trip.id, owner.id, 'Hacked')).toBe(false);
// …and a guest cannot be renamed from a different trip.
expect(membersSvc.renameGuest(otherTrip.id, member.id, 'Nope')).toBe(false);
});
it('TRIP-SVC-033: deleteGuest removes the user (cascading membership), guest-only + trip-scoped', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Carol', owner.id);
// Real members are not deletable via the guest path.
expect(membersSvc.deleteGuest(trip.id, owner.id)).toBe(false);
expect(membersSvc.deleteGuest(trip.id, member.id)).toBe(true);
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(member.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_members WHERE user_id = ?').get(member.id)).toBeUndefined();
});
it('TRIP-SVC-034: a guest is never invitable (addMember) nor a transfer target', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member } = membersSvc.createGuest(trip.id, 'Dora', owner.id);
// The synthetic username/email must not resolve through the invite box.
expect(() => membersSvc.addMember(trip.id, 'Dora', owner.id, owner.id)).toThrow('User not found');
// Ownership can never be handed to a guest.
expect(() => membersSvc.transferOwnership(trip.id, member.id, owner.id)).toThrow('Cannot transfer ownership to a guest');
});
});
// ── Folded CRUD SQL (summary / list / create / delete / copy) ─────────────────
describe('folded trip CRUD', () => {
it('TRIP-SVC-042: getTripSummary aggregates members, days, budget, packing and reservations', () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
addTripMember(testDb, trip.id, member.id);
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'food', 'Dinner', 40)").run(trip.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
const summary = readModelSvc.getTripSummary(trip.id, owner.id)!;
expect(summary).toBeTruthy();
expect((summary.trip as any).id).toBe(trip.id);
expect(summary.members.owner.id).toBe(owner.id);
expect(summary.members.collaborators.map((m: any) => m.id)).toEqual([member.id]);
expect(summary.days).toHaveLength(2);
expect(summary.budget.item_count).toBe(1);
expect(summary.budget.total).toBe(40);
expect(summary.packing.total).toBe(1);
expect(summary.packing.checked).toBe(1);
expect(summary.reservations).toEqual([]);
expect(summary.collab_notes).toEqual([]);
// Missing trips return null instead of throwing.
expect(readModelSvc.getTripSummary(99999)).toBeNull();
});
it('TRIP-SVC-043: list returns owned + shared trips with is_owner, honoring the archived filter', () => {
const { user: owner } = createUser(testDb);
const { user: other } = createUser(testDb);
const own = createTrip(testDb, owner.id, { title: 'Mine' });
const shared = createTrip(testDb, other.id, { title: 'Shared' });
addTripMember(testDb, shared.id, owner.id);
const archived = createTrip(testDb, owner.id, { title: 'Old' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
const active = svc.list(owner.id, 0) as any[];
expect(active.map(t => t.id).sort()).toEqual([own.id, shared.id].sort());
expect(active.find(t => t.id === own.id).is_owner).toBe(1);
expect(active.find(t => t.id === shared.id).is_owner).toBe(0);
const all = svc.list(owner.id, null) as any[];
expect(all.map(t => t.id).sort()).toEqual([own.id, shared.id, archived.id].sort());
});
it('TRIP-SVC-044: create applies || defaults, clamps reminder_days and generates days', () => {
const { user } = createUser(testDb);
const { trip, tripId, reminderDays } = svc.create(user.id, {
title: 'New Trip', start_date: '2025-06-01', end_date: '2025-06-03', reminder_days: 99,
});
expect(reminderDays).toBe(3); // out-of-range → default 3
expect((trip as any).currency).toBe('EUR'); // no currency → 'EUR'
expect(getDays(tripId)).toHaveLength(3);
});
it('TRIP-SVC-045: remove deletes the trip, cleans skeleton journey entries and detaches filled ones', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const journeyId = Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
).run(user.id).lastInsertRowid);
testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id);
const filledId = Number(testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'story', 'F', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id).lastInsertRowid);
const info = svc.remove(trip.id, user.id, 'user');
expect(info).toMatchObject({ tripId: trip.id, ownerId: user.id, isAdminDelete: false });
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeUndefined();
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeUndefined();
const filled = testDb.prepare('SELECT source_trip_id FROM journey_entries WHERE id = ?').get(filledId) as any;
expect(filled.source_trip_id).toBeNull();
// Missing trips throw the byte-identical error.
expect(() => svc.remove(99999, user.id, 'user')).toThrow('Trip not found');
});
it('TRIP-SVC-046: copy duplicates days/places/assignments and resets packing to unchecked', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
const days = getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Louvre' });
createDayAssignment(testDb, days[0].id, place.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked) VALUES (?, 'Socks', 1)").run(trip.id);
const newTripId = svc.copy(trip.id, user.id, 'Clone');
const copied = testDb.prepare('SELECT title, is_archived FROM trips WHERE id = ?').get(newTripId) as any;
expect(copied.title).toBe('Clone');
expect(copied.is_archived).toBe(0);
expect(getDays(newTripId)).toHaveLength(2);
const newPlaces = testDb.prepare('SELECT id, name FROM places WHERE trip_id = ?').all(newTripId) as any[];
expect(newPlaces.map(p => p.name)).toEqual(['Louvre']);
expect(getAssignments(getDays(newTripId)[0].id)).toHaveLength(1);
const packing = testDb.prepare('SELECT checked FROM packing_items WHERE trip_id = ?').all(newTripId) as any[];
expect(packing).toEqual([{ checked: 0 }]);
// No title → source title (|| fallback).
const secondCopy = svc.copy(trip.id, user.id);
expect((testDb.prepare('SELECT title FROM trips WHERE id = ?').get(secondCopy) as any).title).toBe('Origin');
});
/**
* Copying a trip used to take every packing row and re-insert it without
* is_private/owner_id, so both fell back to the column defaults and another
* member's Personal or Shared item reappeared in the copy as a Common item
* that everyone on the new trip could read (GHSA-vh2h-288v-ggch).
*/
it("TRIP-SVC-046b: copy leaves other members' restricted packing items behind", () => {
const { user: owner } = createUser(testDb);
const { user: member } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Origin', start_date: '2025-06-01', end_date: '2025-06-02' });
const ins = testDb.prepare('INSERT INTO packing_items (trip_id, name, checked, is_private, owner_id) VALUES (?, ?, 0, ?, ?)');
ins.run(trip.id, 'Shared tent', 0, null); // Common
ins.run(trip.id, "Owner's diary", 1, owner.id); // the owner's Personal
ins.run(trip.id, "Member's meds", 1, member.id); // the copier's own Personal
const newTripId = svc.copy(trip.id, member.id, 'Copy');
const rows = testDb.prepare('SELECT name, is_private, owner_id FROM packing_items WHERE trip_id = ? ORDER BY name').all(newTripId) as any[];
// The owner's private row is gone, not relabelled as Common.
expect(rows.map(r => r.name)).toEqual(["Member's meds", 'Shared tent']);
expect(rows.find(r => r.name === 'Shared tent')).toMatchObject({ is_private: 0, owner_id: null });
// The copier's own item stays restricted and belongs to them in the copy.
expect(rows.find(r => r.name === "Member's meds")).toMatchObject({ is_private: 1, owner_id: member.id });
});
it('TRIP-SVC-059: copy remaps cross-links and carries splits/participants (smoke-test I-01)', () => {
const { user: owner } = createUser(testDb);
const { user: friend } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Linked', start_date: '2025-06-01', end_date: '2025-06-02' });
const days = getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel Le Test' });
const assignment = createDayAssignment(testDb, days[0].id, place.id);
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, owner.id);
const accomId = Number(testDb.prepare(`
INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out)
VALUES (?, ?, ?, ?, '15:00', '11:00')
`).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid);
const resId = Number(testDb.prepare(`
INSERT INTO reservations (trip_id, day_id, assignment_id, accommodation_id, title, type, url)
VALUES (?, ?, ?, ?, 'Hotel booking', 'hotel', 'https://example.test/booking')
`).run(trip.id, days[0].id, assignment.id, accomId).lastInsertRowid);
const itemId = Number(testDb.prepare(`
INSERT INTO budget_items (trip_id, category, name, total_price, persons, reservation_id, currency, exchange_rate, expense_date)
VALUES (?, 'Accommodation', 'Hotel', 240, 2, ?, 'JPY', 0.0062, '2025-06-01')
`).run(trip.id, resId).lastInsertRowid);
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 1, 120)').run(itemId, owner.id);
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, 120)').run(itemId, friend.id);
testDb.prepare('INSERT INTO budget_item_payers (budget_item_id, user_id, amount) VALUES (?, ?, 240)').run(itemId, owner.id);
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book transfer', 1)").run(trip.id);
const newTripId = svc.copy(trip.id, owner.id, 'Linked copy');
// Budget → reservation link points at the copied reservation, not null / not the old id.
const newItem = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').get(newTripId) as any;
const newRes = testDb.prepare('SELECT * FROM reservations WHERE trip_id = ?').get(newTripId) as any;
expect(newRes.id).not.toBe(resId);
expect(newItem.reservation_id).toBe(newRes.id);
expect(newItem).toMatchObject({ currency: 'JPY', exchange_rate: 0.0062, expense_date: '2025-06-01' });
// Reservation → accommodation resolves to the copied accommodation (accommodation_id is TEXT).
const newAccom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any;
expect(newAccom.id).not.toBe(accomId);
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
expect(newRes.url).toBe('https://example.test/booking');
// Splits carried over with per-member paid flags and amounts.
const members = testDb.prepare('SELECT user_id, paid, amount FROM budget_item_members WHERE budget_item_id = ? ORDER BY user_id').all(newItem.id) as any[];
expect(members).toEqual([
{ user_id: owner.id, paid: 1, amount: 120 },
{ user_id: friend.id, paid: 0, amount: 120 },
]);
const payers = testDb.prepare('SELECT user_id, amount FROM budget_item_payers WHERE budget_item_id = ?').all(newItem.id) as any[];
expect(payers).toEqual([{ user_id: owner.id, amount: 240 }]);
// Assignment participants copied onto the remapped assignment.
const newAssignment = getAssignments(getDays(newTripId)[0].id)[0];
const participants = testDb.prepare('SELECT user_id FROM assignment_participants WHERE assignment_id = ?').all(newAssignment.id) as any[];
expect(participants).toEqual([{ user_id: owner.id }]);
// To-dos come across but reset to unchecked (documented behaviour).
const todos = testDb.prepare('SELECT name, checked FROM todo_items WHERE trip_id = ?').all(newTripId) as any[];
expect(todos).toEqual([{ name: 'Book transfer', checked: 0 }]);
});
});
// ── Wrapper helpers (delegating members of the aggregate root) ────────────────
describe('TripsService wrapper helpers', () => {
it('re-anchors the budget before the trip row leaves its old currency (#1543)', async () => {
const order: string[] = [];
const rebaseSpy = vi.spyOn(budgetSvc, 'rebaseTripCurrency').mockImplementation(async () => { order.push('rebase'); });
const updateSpy = vi.spyOn(svc, 'updateTrip').mockImplementation(() => { order.push('update'); return {} as never; });
try {
await svc.update('9', 1, { currency: 'RUB' } as never, 'user');
// The rebase reads the outgoing currency off the trip row, so it has to run first.
expect(rebaseSpy).toHaveBeenCalledWith('9', 'RUB');
expect(order).toEqual(['rebase', 'update']);
} finally {
rebaseSpy.mockRestore();
updateSpy.mockRestore();
}
});
it('canAccessTrip delegates to the db helper; can() delegates to checkPermission; broadcast forwards', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(svc.canAccessTrip(String(trip.id), user.id)).toMatchObject({ user_id: user.id });
expect(svc.can('trip_edit', 'user', user.id, user.id, false)).toBe(true);
svc.broadcast('9', 'trip:updated', { a: 1 } as never, 'sock');
expect(broadcast).toHaveBeenCalledWith('9', 'trip:updated', { a: 1 }, 'sock');
});
it('getCopiedTrip re-reads via the TRIP_SELECT query', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Copied' });
const row = svc.getCopiedTrip(trip.id, user.id) as any;
expect(row.id).toBe(trip.id);
expect(row.is_owner).toBe(1);
});
it('bundle aggregates every sub-collection + the member list, scoping packing to the viewer (#858)', () => {
const { user: owner } = createUser(testDb);
const { user: viewer } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { start_date: '2025-06-01', end_date: '2025-06-02' });
addTripMember(testDb, trip.id, viewer.id);
// A personal item of the OWNER must stay out of the other member's bundle.
testDb.prepare("INSERT INTO packing_items (trip_id, name, is_private, owner_id) VALUES (?, 'Secret', 1, ?)").run(trip.id, owner.id);
testDb.prepare("INSERT INTO packing_items (trip_id, name) VALUES (?, 'Shared')").run(trip.id);
const result = readModelSvc.bundle(String(trip.id), { user_id: owner.id }, viewer.id) as any;
expect(result.days).toHaveLength(2);
expect(result.members.map((m: any) => m.id).sort()).toEqual([owner.id, viewer.id].sort());
expect(result.packingItems.map((p: any) => p.name)).toEqual(['Shared']);
});
it('notifyInvite is fire-and-forget (no throw)', () => {
expect(() => membersSvc.notifyInvite('9', { id: 1, email: 'a@b.c' } as never, 2, 'T', 'b@x.y')).not.toThrow();
});
});
// ── Branch coverage for the folded update/export/copy quirks ─────────────────
describe('folded quirk branches', () => {
it('TRIP-SVC-047: updateTrip admin edit collects changes and the owner email; reminder 0 reads "none"', () => {
const { user: owner } = createUser(testDb);
const { user: admin } = createUser(testDb);
testDb.prepare("UPDATE users SET role = 'admin' WHERE id = ?").run(admin.id);
const trip = createTrip(testDb, owner.id, { title: 'Old' });
const result = svc.updateTrip(trip.id, admin.id, { title: 'New', is_archived: true, reminder_days: 0 }, 'admin');
expect(result.isAdminEdit).toBe(true);
expect(result.ownerEmail).toBe(owner.email);
expect(result.changes).toMatchObject({ title: 'New', archived: true, reminder_days: 'none' });
expect(result.newTitle).toBe('New');
// || coercion: an empty-string title falls back to the stored one.
const kept = svc.updateTrip(trip.id, owner.id, { title: '' }, 'user');
expect(kept.newTitle).toBe('New');
// Missing trips throw the byte-identical error; invalid ranges reject.
expect(() => svc.updateTrip(99999, owner.id, {}, 'user')).toThrow('Trip not found');
expect(() => svc.updateTrip(trip.id, owner.id, { start_date: '2025-06-10', end_date: '2025-06-01' }, 'user')).toThrow('End date must be after start date');
});
it('TRIP-SVC-049: addMember inserts the membership and reports the trip title; removeMember deletes it', () => {
const { user: owner } = createUser(testDb);
const { user: invitee } = createUser(testDb);
const trip = createTrip(testDb, owner.id, { title: 'Joinable' });
const result = membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id);
expect(result.member.id).toBe(invitee.id);
expect(result.tripTitle).toBe('Joinable');
expect(result.targetUserId).toBe(invitee.id);
// Duplicate + owner + missing identifier reject with the byte-identical errors.
expect(() => membersSvc.addMember(trip.id, invitee.email, owner.id, owner.id)).toThrow('User already has access');
expect(() => membersSvc.addMember(trip.id, owner.email, owner.id, owner.id)).toThrow('Trip owner is already a member');
expect(() => membersSvc.addMember(trip.id, '', owner.id, owner.id)).toThrow('Email or username required');
membersSvc.removeMember(trip.id, invitee.id);
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, invitee.id)).toBeUndefined();
});
it('TRIP-SVC-050: copy remaps tags, accommodations, reservations, day notes, budget, bags and category order', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Deep', start_date: '2025-06-01', end_date: '2025-06-02' });
const days = getDays(trip.id);
const place = createPlace(testDb, trip.id, { name: 'Hotel Zed' });
const assignment = createDayAssignment(testDb, days[0].id, place.id);
const tagId = Number(testDb.prepare("INSERT INTO tags (name, user_id) VALUES ('beach', ?)").run(user.id).lastInsertRowid);
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tagId);
const accomId = Number(testDb.prepare(
"INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out) VALUES (?, ?, ?, ?, '15:00', '11:00')",
).run(trip.id, place.id, days[0].id, days[1].id).lastInsertRowid);
testDb.prepare(
'INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, accommodation_id, title, type) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
).run(trip.id, days[0].id, days[1].id, place.id, assignment.id, accomId, 'Stay', 'hotel');
testDb.prepare("INSERT INTO budget_items (trip_id, category, name, total_price) VALUES (?, 'stay', 'Hotel', 120)").run(trip.id);
const bagId = Number(testDb.prepare("INSERT INTO packing_bags (trip_id, name) VALUES (?, 'Backpack')").run(trip.id).lastInsertRowid);
testDb.prepare("INSERT INTO packing_items (trip_id, name, checked, bag_id) VALUES (?, 'Towel', 1, ?)").run(trip.id, bagId);
createDayNote(testDb, days[0].id, trip.id, { text: 'note' });
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked) VALUES (?, 'Book', 1)").run(trip.id);
testDb.prepare("INSERT INTO budget_category_order (trip_id, category, sort_order) VALUES (?, 'stay', 2)").run(trip.id);
const newTripId = svc.copy(trip.id, user.id);
const newDays = getDays(newTripId);
expect(newDays).toHaveLength(2);
const newPlace = testDb.prepare('SELECT id FROM places WHERE trip_id = ?').get(newTripId) as { id: number };
expect(testDb.prepare('SELECT tag_id FROM place_tags WHERE place_id = ?').get(newPlace.id)).toEqual({ tag_id: tagId });
const newAccom = testDb.prepare('SELECT id, place_id, start_day_id, end_day_id FROM day_accommodations WHERE trip_id = ?').get(newTripId) as any;
expect(newAccom.place_id).toBe(newPlace.id);
expect(newAccom.start_day_id).toBe(newDays[0].id);
const newRes = testDb.prepare('SELECT day_id, end_day_id, place_id, accommodation_id FROM reservations WHERE trip_id = ?').get(newTripId) as any;
// The legacy copyTripById nulled this link (TEXT column vs number-keyed map);
// fixed with smoke-test I-01 — the copy now coerces and remaps it.
expect(newRes.day_id).toBe(newDays[0].id);
expect(newRes.end_day_id).toBe(newDays[1].id);
expect(newRes.place_id).toBe(newPlace.id);
expect(Number(newRes.accommodation_id)).toBe(newAccom.id);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM budget_items WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
const newItem = testDb.prepare('SELECT checked, bag_id FROM packing_items WHERE trip_id = ?').get(newTripId) as any;
expect(newItem.checked).toBe(0);
expect(newItem.bag_id).not.toBeNull();
expect((testDb.prepare('SELECT checked, assigned_user_id FROM todo_items WHERE trip_id = ?').get(newTripId) as any)).toEqual({ checked: 0, assigned_user_id: null });
expect((testDb.prepare('SELECT sort_order FROM budget_category_order WHERE trip_id = ?').get(newTripId) as any).sort_order).toBe(2);
expect((testDb.prepare('SELECT COUNT(*) AS n FROM day_notes WHERE trip_id = ?').get(newTripId) as any).n).toBe(1);
// Missing source throws the byte-identical error.
expect(() => svc.copy(99999, user.id)).toThrow('Trip not found');
});
});
// ── Post-fold quirk fixes (transactions, owner display name) ─────────────────
describe('quirk fixes', () => {
/** A TripsService whose connection throws when preparing SQL matching `match`. */
function failingConnection(match: string) {
const conn = new Proxy(testDb, {
get(target, prop) {
if (prop === 'prepare') {
return (sql: string) => {
if (sql.includes(match)) throw new Error('boom');
return target.prepare(sql);
};
}
const v = (target as any)[prop];
return typeof v === 'function' ? v.bind(target) : v;
},
});
return { connection: conn, canAccessTrip: dbMock.canAccessTrip, isOwner: dbMock.isOwner } as unknown as import('../../../src/nest/database/database.service').DatabaseService;
}
function failingTrips(match: string) {
const fdbs = failingConnection(match);
return new TripsService(
fdbs,
new ReservationsService(dbs(), new PermissionsService(dbs()), budgetSvc, new RealtimeService(), notificationsStub(), new ReservationsReadRepository(dbs())),
daysSvc,
new PermissionsService(dbs()),
budgetSvc,
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new RealtimeService(),
undefined as never,
coversFx.storage,
);
}
/** Same frozen connection, for the guest deletion that now lives on the roster. */
function failingMembers(match: string) {
return new TripMembersService(
failingConnection(match), budgetSvc, new UserCleanupService(dbs(), budgetSvc),
new PermissionsService(dbs()), new RealtimeService(),
notificationsStub(),
);
}
it('TRIP-SVC-051: remove is atomic — a failed trip DELETE keeps the journey entries intact', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const journeyId = Number(testDb.prepare(
"INSERT INTO journeys (user_id, title, created_at, updated_at) VALUES (?, 'J', 0, 0)",
).run(user.id).lastInsertRowid);
testDb.prepare(
"INSERT INTO journey_entries (journey_id, source_trip_id, author_id, type, title, entry_date, created_at, updated_at) VALUES (?, ?, ?, 'skeleton', 'S', '2025-06-01', 0, 0)",
).run(journeyId, trip.id, user.id);
const broken = failingTrips('DELETE FROM trips WHERE id = ?');
expect(() => broken.remove(trip.id, user.id, 'user')).toThrow('boom');
// The skeleton cleanup rolled back with the failed delete.
expect(testDb.prepare("SELECT id FROM journey_entries WHERE type = 'skeleton'").get()).toBeDefined();
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id)).toBeDefined();
});
it('TRIP-SVC-052: deleteGuest is atomic — a failed user DELETE rolls the budget re-split back', () => {
const { user: owner } = createUser(testDb);
const trip = createTrip(testDb, owner.id);
const { member: guest } = membersSvc.createGuest(trip.id, 'Gia', owner.id);
const item = budgetSvc.createBudgetItem(trip.id, { name: 'Dinner', total_price: 80, member_ids: [owner.id, guest.id] });
const broken = failingMembers('DELETE FROM users WHERE id = ? AND is_guest = 1');
expect(() => broken.deleteGuest(trip.id, guest.id)).toThrow('boom');
// Neither the guest nor their split membership was touched.
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guest.id)).toBeDefined();
const row = testDb.prepare('SELECT persons FROM budget_items WHERE id = ?').get(item.id) as { persons: number | null };
expect(row.persons).toBe(2);
});
it('TRIP-SVC-053: listMembers prefers the owner display_name over the raw username (quirk fix)', () => {
const { user: owner } = createUser(testDb, { username: 'owner-handle' });
testDb.prepare('UPDATE users SET display_name = ? WHERE id = ?').run('Olive Displayed', owner.id);
const trip = createTrip(testDb, owner.id);
const { owner: row } = membersSvc.listMembers(trip.id, owner.id);
expect(row.username).toBe('Olive Displayed');
});
});
/**
* activeTrip powers the startup redirect, so its order has to stay identical to
* the dashboard's sortTrips (client/src/pages/dashboard/dashboardModel.ts):
* running today → next one starting (earliest first) → most recently started →
* undated last. If these drift, "open my trip" and the hero show different trips.
*/
describe('activeTrip (startup destination)', () => {
const TODAY = '2026-08-08';
it('TRIP-SVC-054: prefers the trip running today over anything upcoming', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'soon', start_date: '2026-08-20', end_date: '2026-08-25' });
createTrip(testDb, user.id, { title: 'running', start_date: '2026-08-05', end_date: '2026-08-12' });
expect(svc.activeTrip(user.id, TODAY)?.title).toBe('running');
});
it('TRIP-SVC-055: without a running trip it takes the next one starting, earliest first', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'late', start_date: '2026-12-01', end_date: '2026-12-10' });
createTrip(testDb, user.id, { title: 'soon', start_date: '2026-09-01', end_date: '2026-09-10' });
expect(svc.activeTrip(user.id, TODAY)?.title).toBe('soon');
});
it('TRIP-SVC-056: falls back to the most recently started trip, undated ones last', () => {
const { user } = createUser(testDb);
createTrip(testDb, user.id, { title: 'undated' });
createTrip(testDb, user.id, { title: 'old', start_date: '2020-01-01', end_date: '2020-01-10' });
createTrip(testDb, user.id, { title: 'recent', start_date: '2026-07-01', end_date: '2026-07-10' });
expect(svc.activeTrip(user.id, TODAY)?.title).toBe('recent');
});
it('TRIP-SVC-057: skips archived trips and returns undefined when nothing is left', () => {
const { user } = createUser(testDb);
const archived = createTrip(testDb, user.id, { title: 'archived', start_date: '2026-08-05', end_date: '2026-08-12' });
testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id);
expect(svc.activeTrip(user.id, TODAY)).toBeUndefined();
});
it('TRIP-SVC-058: sees shared trips but never another user\'s private ones', () => {
const { user: owner } = createUser(testDb);
const { user: guest } = createUser(testDb);
createTrip(testDb, owner.id, { title: 'private', start_date: '2026-08-05', end_date: '2026-08-12' });
const shared = createTrip(testDb, owner.id, { title: 'shared', start_date: '2026-09-01', end_date: '2026-09-10' });
addTripMember(testDb, shared.id, guest.id);
expect(svc.activeTrip(guest.id, TODAY)?.title).toBe('shared');
});
});