Files
TREK/server/vitest.config.ts
T
Maurice 2d7cb124b3 feat(documents): two-way sync with Paperless, Papra, Nextcloud, OpenCloud and Synology
A trip can be bound to a folder, tag or space in a self-hosted document store,
and documents then move in both directions: what someone uploads in TREK appears
there, and what someone files there appears in TREK.

Three decisions shape the rest.

The connection belongs to the TRIP, not to a person. Every other integration
here is per user, and photos are shared only through an opt-in flag; neither can
deliver "everyone on the trip sees the same documents", because visibility would
depend on whose credentials fetched a file, and a member without their own
Paperless account would see nothing. So the trip owner binds the trip once, the
server talks to the provider under that one identity, and TREK's own membership
decides who sees what. The provider never learns that TREK has members. When the
credential holder leaves the trip the binding goes to `orphaned` rather than
quietly carrying on with an ex-member's token.

TREK keeps the bytes. A synced document is an ordinary trip_files row, so
download, trash, offline cache, PDF export and backup keep working untouched,
and a provider outage does not blank out a trip that starts tomorrow.

Deletion never crosses the boundary on its own. A document that disappears
upstream is recorded as missing and waits for a human, and a run that sees more
than 40 percent of its known documents vanish at once refuses the whole listing
rather than acting on it: an unmounted share answers exactly like a mass delete.

The core reconciles by enumerating both sides, because the weakest provider sets
the model. A changed-since query cannot see a deletion, Papra does not move
updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and
Synology FileStation has no change feed at all. A webhook is therefore only ever
"look now", never a source of truth. Echo is caught by comparing content, not
timestamps: pushed_sha256 records what TREK itself wrote, so a provider
reporting TREK's own upload is not mistaken for a stranger's edit.

Provider differences are answered by capability data rather than by branching on
an id: stable ids where they exist (Paperless document id, oc:fileid, driveId)
and a normalised path where they do not, replace-in-place where the API offers
it, and a content hash from the listing only where the provider really supplies
one.

Admins switch providers on under Admin, Addons, Documents, mirroring how photo
providers hang off Journey, including the cascade and the 409 when the parent
addon is off. The per-trip setup lives in the file manager, where the person has
the context that makes the folder choice obvious.
2026-09-18 19:23:07 +02:00

195 lines
13 KiB
TypeScript

import { defineConfig } from 'vitest/config';
import swc from 'unplugin-swc';
export default defineConfig({
// SWC transform so NestJS decorator metadata is emitted in tests
// (vitest's default esbuild does not emit it -> type-based DI would break).
plugins: [
swc.vite({
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
keepClassNames: true,
},
}),
],
test: {
root: '.',
include: ['tests/**/*.test.ts'],
globals: true,
setupFiles: ['tests/setup.ts', 'tests/setup.console-noise.ts'],
testTimeout: 15000,
hookTimeout: 15000,
pool: 'forks',
silent: false,
reporters: ['verbose'],
coverage: {
// Vite 8 + Vitest 4 made the sourcemap-based `v8` provider under-report branch
// coverage on the SWC/decorator-transformed output (it dropped to ~68% even
// though every test passes). `istanbul` instruments the source directly, so
// coverage is measured independently of the transform pipeline.
provider: 'istanbul',
// json-summary is what the per-domain ratchet below is derived from: the text
// reporter prints one row per DIRECTORY, not a recursive total, so reading the
// thresholds off it silently understates any domain with subdirectories.
reporter: ['lcov', 'text', 'json-summary'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
// The plugin child bootstrap runs in a forked subprocess via tsx, so the
// parent's instrumentation can't measure it; it's exercised end-to-end by
// the supervisor integration test instead. Everything else in the plugin
// module runs in-process and is unit-tested.
exclude: ['src/nest/plugins/runtime/plugin-host-entry.ts'],
// Coverage gate scoped to the new NestJS code only — the legacy codebase is
// intentionally ungated.
//
// The bar is PER DOMAIN and works as a ratchet: each entry is that domain's
// measured coverage at the time it was set, minus one point of slack. Regenerate
// with `node scripts/coverage-thresholds.mjs` after a run that RAISED coverage. A single
// repo-wide 80% let a well-covered domain subsidise a thin one, so a domain could
// lose ten points and the build stayed green. Now it cannot: the only way past a
// number here is to raise it, which makes the ratchet visible in the diff.
//
// Raise an entry when you improve a domain. Never lower one to make a build pass —
// that is the whole point of the mechanism.
//
// Regenerate from a LINUX run if you can: CI runs on Linux, and a few branches are
// platform-specific (the drive-letter half of the backup zip-slip guard is only
// reachable on win32). Numbers taken on Windows can therefore sit a hair above what
// CI measures, which shows up as a threshold failure that reproduces nowhere local.
//
// A file matched by a glob below is EXCLUDED from the catch-all, so the last entry
// only covers what sits directly under src/nest (app.module.ts and the like).
thresholds: {
'src/nest/accommodations/**/*.ts': { statements: 92, branches: 87, functions: 99, lines: 97 },
'src/nest/addons/**/*.ts': { statements: 99, branches: 89, functions: 99, lines: 99 },
'src/nest/admin/**/*.ts': { statements: 86, branches: 70, functions: 89, lines: 88 },
'src/nest/airports/**/*.ts': { statements: 68, branches: 60, functions: 93, lines: 73 },
'src/nest/app-config/**/*.ts': { statements: 79, branches: 100, functions: 65, lines: 71 },
'src/nest/assignments/**/*.ts': { statements: 90, branches: 80, functions: 97, lines: 96 },
'src/nest/atlas/**/*.ts': { statements: 92, branches: 82, functions: 96, lines: 94 },
'src/nest/audit/**/*.ts': { statements: 93, branches: 80, functions: 99, lines: 96 },
'src/nest/auth/**/*.ts': { statements: 93, branches: 85, functions: 95, lines: 96 },
'src/nest/backup/**/*.ts': { statements: 97, branches: 93, functions: 99, lines: 98 },
'src/nest/booking-import/**/*.ts': { statements: 50, branches: 28, functions: 64, lines: 53 },
'src/nest/budget/**/*.ts': { statements: 91, branches: 77, functions: 97, lines: 95 },
'src/nest/calendar/**/*.ts': { statements: 96, branches: 93, functions: 99, lines: 99 },
'src/nest/categories/**/*.ts': { statements: 99, branches: 80, functions: 99, lines: 99 },
'src/nest/collab/**/*.ts': { statements: 92, branches: 85, functions: 95, lines: 97 },
'src/nest/collections/**/*.ts': { statements: 86, branches: 75, functions: 95, lines: 95 },
'src/nest/common/**/*.ts': { statements: 95, branches: 89, functions: 99, lines: 97 },
'src/nest/config/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/database/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/day-notes/**/*.ts': { statements: 92, branches: 83, functions: 99, lines: 99 },
'src/nest/days/**/*.ts': { statements: 92, branches: 82, functions: 98, lines: 97 },
'src/nest/doc-sync/**/*.ts': { statements: 91, branches: 79, functions: 96, lines: 95 },
'src/nest/feeds/**/*.ts': { statements: 91, branches: 83, functions: 83, lines: 91 },
'src/nest/files/**/*.ts': { statements: 97, branches: 95, functions: 99, lines: 98 },
'src/nest/geo/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 },
'src/nest/health/**/*.ts': { statements: 99, branches: 65, functions: 99, lines: 99 },
'src/nest/help/**/*.ts': { statements: 81, branches: 70, functions: 99, lines: 86 },
'src/nest/integrations/**/*.ts': { statements: 78, branches: 68, functions: 78, lines: 80 },
'src/nest/journey/**/*.ts': { statements: 91, branches: 84, functions: 88, lines: 93 },
'src/nest/llm-parse/**/*.ts': { statements: 91, branches: 85, functions: 85, lines: 94 },
'src/nest/maps/**/*.ts': { statements: 93, branches: 86, functions: 97, lines: 96 },
'src/nest/memories/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 },
'src/nest/notifications/**/*.ts': { statements: 83, branches: 72, functions: 85, lines: 87 },
'src/nest/oauth/**/*.ts': { statements: 96, branches: 95, functions: 97, lines: 97 },
'src/nest/oidc/**/*.ts': { statements: 88, branches: 83, functions: 91, lines: 92 },
'src/nest/packing/**/*.ts': { statements: 92, branches: 83, functions: 99, lines: 97 },
'src/nest/permissions/**/*.ts': { statements: 97, branches: 92, functions: 99, lines: 97 },
'src/nest/photos/**/*.ts': { statements: 96, branches: 92, functions: 92, lines: 96 },
// Landed on dev without an entry, so it sat on the 80 catch-all while
// measuring 99.5/99.0/97.4/100 — the gap the per-domain ratchet exists to
// close. Set with a few points of slack against the Linux/Windows drift.
'src/nest/place-enrichment/**/*.ts': { statements: 96, branches: 95, functions: 94, lines: 97 },
'src/nest/place-photos/**/*.ts': { statements: 87, branches: 79, functions: 72, lines: 89 },
// New domain in this change. Measured over its own suites at
// 98.3/89.8/96.2/100, and pinned well under that on purpose: the
// subset run does not load the controller the way the full run does,
// so the real figure is not this one. Regenerate with
// scripts/coverage-thresholds.mjs after a full run. What matters now is
// that it is off the 80 catch-all, where a new domain could shed twenty
// points without anything noticing.
'src/nest/place-shadow/**/*.ts': { statements: 90, branches: 80, functions: 90, lines: 95 },
'src/nest/places/**/*.ts': { statements: 91, branches: 82, functions: 96, lines: 94 },
'src/nest/platform/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
'src/nest/plugins/**/*.ts': { statements: 86, branches: 81, functions: 78, lines: 89 },
'src/nest/query-helpers/**/*.ts': { statements: 90, branches: 75, functions: 99, lines: 92 },
'src/nest/realtime/**/*.ts': { statements: 99, branches: 100, functions: 99, lines: 99 },
'src/nest/reservation-import/**/*.ts': { statements: 61, branches: 55, functions: 41, lines: 61 },
'src/nest/reservations/**/*.ts': { statements: 92, branches: 83, functions: 96, lines: 96 },
// New domain in this change. Measured over its own suites at
// 94.0/81.1/100/99.1 and pinned under that: the subset run does not
// load the controller the way the full run does, so the real figure is
// not this one. Regenerate with scripts/coverage-thresholds.mjs after a
// full run. What matters now is that it carries its own floor instead of
// hiding in the 80 catch-all, where 557 new lines could shed twenty
// points against the headroom of the domains beside them.
'src/nest/roadtrip/**/*.ts': { statements: 88, branches: 80, functions: 95, lines: 95 },
'src/nest/settings/**/*.ts': { statements: 87, branches: 71, functions: 99, lines: 88 },
'src/nest/share/**/*.ts': { statements: 97, branches: 87, functions: 99, lines: 99 },
'src/nest/storage/**/*.ts': { statements: 94, branches: 84, functions: 97, lines: 94 },
'src/nest/system-notices/**/*.ts': { statements: 99, branches: 99, functions: 99, lines: 99 },
'src/nest/tags/**/*.ts': { statements: 97, branches: 89, functions: 99, lines: 99 },
'src/nest/todo/**/*.ts': { statements: 90, branches: 82, functions: 99, lines: 99 },
'src/nest/transit/**/*.ts': { statements: 92, branches: 83, functions: 97, lines: 94 },
'src/nest/trip-invite/**/*.ts': { statements: 91, branches: 93, functions: 93, lines: 89 },
'src/nest/trip-membership/**/*.ts': { statements: 99, branches: 86, functions: 99, lines: 99 },
'src/nest/trip-members/**/*.ts': { statements: 95, branches: 91, functions: 99, lines: 95 },
'src/nest/trip-read-model/**/*.ts': { statements: 97, branches: 96, functions: 99, lines: 97 },
'src/nest/trips/**/*.ts': { statements: 95, branches: 86, functions: 95, lines: 96 },
'src/nest/unsplash/**/*.ts': { statements: 99, branches: 88, functions: 99, lines: 99 },
'src/nest/vacay/**/*.ts': { statements: 82, branches: 66, functions: 90, lines: 86 },
'src/nest/weather/**/*.ts': { statements: 93, branches: 78, functions: 91, lines: 97 },
'src/nest/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
// Outside the container. Coverage MEASURES src/**, but until now only
// src/nest/** was gated, so the boot path, the scheduler, the MCP
// handler and the migrations sat under no floor at all — which is
// exactly where the worst finding of the last audit lived (a swallowed
// EADDRINUSE that no test could have caught). These entries pin what is
// measured today so it cannot slide; they are floors to raise, not
// targets that have been met.
//
// src/demo/** is deliberately absent: it measures 0%, and a floor of
// zero asserts nothing. It needs tests before it needs a threshold.
'src/app-config/**/*.ts': { statements: 99, branches: 95, functions: 99, lines: 99 },
'src/db/**/*.ts': { statements: 73, branches: 38, functions: 59, lines: 80 },
'src/mcp/**/*.ts': { statements: 58, branches: 43, functions: 63, lines: 60 },
'src/middleware/**/*.ts': { statements: 91, branches: 89, functions: 87, lines: 94 },
// The folded-in nest-mcp decorator/registry layer keeps the 80% floor
// its own workspace gate enforced (tests/unit/nest-mcp/).
'src/nest-mcp/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
'src/systemNotices/**/*.ts': { statements: 84, branches: 67, functions: 93, lines: 85 },
'src/utils/**/*.ts': { statements: 92, branches: 87, functions: 89, lines: 96 },
// index.ts, bootstrap.ts, scheduler.ts, config.ts, websocket.ts and the
// small top-level modules beside them.
'src/*.ts': { statements: 39, branches: 36, functions: 36, lines: 42 },
},
},
},
resolve: {
alias: {
// MCP SDK's exports map uses extension-less wildcard targets that neither
// Node nor Vite can resolve. Point directly at the CJS dist files.
// Paths are relative to the monorepo root (packages are hoisted there).
'@modelcontextprotocol/sdk/server/mcp': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
import.meta.url
).pathname,
'@modelcontextprotocol/sdk/server/streamableHttp': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js',
import.meta.url
).pathname,
'@modelcontextprotocol/sdk/inMemory': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js',
import.meta.url
).pathname,
'@modelcontextprotocol/sdk/client/index': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js',
import.meta.url
).pathname,
},
},
});