mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
Release 3.1.0 (#1185)
* Phase 0 — NestJS + Zod foundation harness (F1–F8) (#1050) Co-hosted NestJS app behind the existing Express server via a strangler-fig dispatcher, sharing the same better-sqlite3 connection and JWT httpOnly cookie. Additive and dormant: default routing stays on Express, Nest only serves its own /api/_nest diagnostics until a module opts in. F1 @trek/shared Zod contract package; F2 Nest bootstrap co-hosted (fall-through, single Dockerfile/port); F3 shared better-sqlite3 provider; F4 JWT cookie auth guard (+ @CurrentUser, admin guard); F5 Zod validation pipe + error-envelope parity; F6 Nest test + coverage gates; F7 per-prefix strangler toggle (env, default Express); F8 CI build/typecheck/test/coverage. Remaining F4/F6/F8 checklist items (trip-access + permission levels + MFA policy, e2e harness/seed + 80% gate, Nest↔Express parity test, Playwright PR-comment workflow) are tracked on the first consuming module cards (L1/A1/C1). * feat(weather): migrate /api/weather to the NestJS pilot module (L1) (#1053) First strangler migration (L1): /api/weather is served by a NestJS module. - @trek/shared/weather Zod contract; Nest controller byte-identical to the legacy Express route (paths, query params, status codes, { error } bodies, lang default, ApiError/500 passthrough). Service reuses getWeather/getDetailedWeather (+ shared cache; MCP tools unchanged). - Strangler routes /api/weather to Nest by default; the legacy Express route + its migration-time parity test were decommissioned in this PR. - Frontend (FE2): weatherApi typed against the @trek/shared WeatherResult contract. - Harness: reusable Nest-vs-Express parity harness, e2e harness (temp SQLite + seed/cookie helpers, real JwtAuthGuard), src/nest coverage gate raised to >=80%, src/nest test guide. - Verified end-to-end on a prod mirror (dev1): 401/400/200 via Nest with real Open-Meteo data, Express route gone. * fix(packing): multiply item weight by quantity in bag/total weight calcs (#898) Quantity now counts toward bag and total weights. Generalised to an itemWeight() helper used by every weight sum (bag totals + max, unassigned, grand total; sidebar + bag modal) with unit tests. * feat(i18n): add Korean (ko) translation (#977) Korean translation by @ppuassi, topped up to full en.ts key parity. Language registration follows separately. * feat(i18n): add Japanese (ja) translation (#829) Japanese translation by @soma3978, at full en.ts key parity, registered in supportedLanguages + TranslationContext. * Add Turkish (tr) translation + language registry (#1029) Turkish translation by @SkyLostTR, at full en.ts key parity, registered in supportedLanguages + TranslationContext. * i18n: register Korean + add Ukrainian translation (#1055) Korean translation by @ppuassi (#977) — now registered. Ukrainian by @JeffyOLOLO (#902) — lifted onto a clean branch. Both at full en.ts key parity (2258 keys). * chore: fix monorepo build pipeline and migrate shared to built package (#1056) * chore: fix monorepo build pipeline and migrate shared to built package - Root package.json: add workspace scripts (dev, build, test, test:cov, test:e2e) that delegate to actual scripts in shared/server/client workspaces - shared: add tsup build step (CJS + ESM dual output, .d.ts); consumers now import from the built dist instead of raw TS source via path aliases - server: replace tsc-alias with tsconfig-paths (tsc-alias mangled node_modules paths); fix MCP SDK path aliases to point to root node_modules (../node_modules) - server/scripts/dev.mjs: delay node --watch until tsc -w signals first-pass done, eliminating the spurious restart on every dev startup - client/vite.config.js + vitest.config.ts: remove @trek/shared path alias (no longer needed now that shared is a proper package) - Consolidate package-lock.json at the workspace root; drop per-workspace lock files * chore: fix test script to reflect root package.json * chore: add missing lint and prettier script in root package.json * fix(ci): build shared before tests; fix vitest MCP SDK alias paths vitest.config.ts aliases pointed at ./node_modules/ (server-local) but packages are hoisted to the root node_modules/ in the npm workspace — changed to ../node_modules/. CI jobs now install and build shared before running server/client tests so that @trek/shared's dist/ exists when vitest resolves the package. * fix(docker): update Dockerfile and CI for monorepo workspace structure Dockerfile: - Add shared-builder stage that produces @trek/shared dist before client and server stages need it - Each build stage carries root package.json + package-lock.json so npm can resolve @trek/shared as a workspace dependency - Production stage installs via workspace context (npm ci --workspace=server --omit=dev) so node_modules/@trek/shared symlinks to shared/dist correctly - Copy server/tsconfig.json into the image so tsconfig-paths/register can find the MCP SDK path aliases at runtime - CMD cds into /app/server before starting node so tsconfig-paths baseUrl resolves and ../node_modules points to /app/node_modules - Remove mkdir for /app/server (now a real dir); keep symlinks for uploads/data docker.yml version-bump: - Replace manual per-workspace cd+npm-version calls with single: npm version --workspaces --include-workspace-root --no-git-tag-version (mirrors the version:* scripts in root package.json) - git add now references root package-lock.json; adds shared/package.json .dockerignore: add shared/dist package.json: fix version:prerelease preid (alpha → pre) * fix(tests): use in-memory SQLite per worker in test mode vitest pool:forks spawns parallel worker processes that all called initDb() on the same data/travel.db, causing SQLite "database is locked" and "duplicate column name" races. When NODE_ENV=test each fork now gets an isolated :memory: DB so migrations run independently with no file contention. * chore(ci): add ACT guards to skip DockerHub steps in local act runs act sets ACT=true automatically. Guards added: - docker login: if: ${{ !env.ACT }} - build outputs: type=docker (local load) when ACT, push-by-digest when CI - digest export/upload: if: ${{ !env.ACT }} - merge job: if: ${{ !env.ACT }} - release-helm job (docker.yml): if: ${{ !env.ACT }} - version-bump git push (docker.yml): wrapped in [ -z "$ACT" ] shell guard Run locally with: ./bin/act -j build -W .github/workflows/docker.yml \ -P ubuntu-latest=catthehacker/ubuntu:act-latest * fix(ci): move ACT guards to step level; add guards to security.yml env context is invalid in job-level if conditions — moved all ACT guards down to individual steps. Also guards docker login + scout in security.yml so act can run the build-only part of that workflow. * fix(ci): skip git fetch and tag logic in act (no remote access in local containers) * Revert "fix(ci): skip git fetch and tag logic in act (no remote access in local containers)" This reverts commit67cf290cda. * Revert "fix(ci): move ACT guards to step level; add guards to security.yml" This reverts commitf92b95e054. * Revert "chore(ci): add ACT guards to skip DockerHub steps in local act runs" This reverts commit797183de08. * fix(docker): add musl optional deps so alpine builds find native rollup/sharp binaries npm prunes libc-constrained optional deps to the host libc (glibc) when generating the lockfile, leaving no musl entry for Alpine containers. Declaring the x64/arm64 musl variants as explicit root optionalDependencies forces them into the lockfile so npm ci on Alpine can install them. Covers shared-builder (tsup/rollup) and client-builder (vite/rollup + sharp icon generation) for both linux/amd64 and linux/arm64 CI targets. * fix(docker): copy client dist into server/public so the server resolves static files correctly The server runs from /app/server and serves static files relative to that directory, so the client build output must land at /app/server/public, not /app/public. * feat(planner): real road routes (OSRM) with travel-time connectors (#1060) * feat(planner): real road routes (OSRM) with travel-time connectors Replace the straight-line "as the crow flies" route with real OSRM road geometry (FOSSGIS routed-car/-foot) and an Apple-Maps style render (blue casing under a lighter core) on both the Leaflet and Mapbox GL maps. Routes are off by default and toggled per session, with a driving/walking mode switch in the day footer. Each day shows per-segment travel time/distance connectors between places, computed from the OSRM legs and split at transport bookings. Also redesigns the day header for visual consistency: vertical number+weather capsule, name with a divider before the date, subtle hotel/rental pills that stay on one line, and a hover-revealed 2x2 action square (edit / add transport / add note / collapse). Drops the Google Maps button. * test(planner): update route hook tests for calculateRouteWithLegs * remove route_calculation setting, always use OSRM routing (#1064) The per-user route_calculation toggle was a second, hidden on/off layer on top of the day footer's show-route button, and made it easy to end up with straight-line routes for no obvious reason. Drop the setting entirely: routing is always on, the footer toggle stays the single switch. Old stored values are simply ignored (settings are key-value, no migration needed). * chore: move i18n to shared package (#1066) * chore: move i18n to shared package * chore: move server translations to shared package and apply linter and prettier on entire shared package * feat(dashboard): upcoming reservations endpoint + travel-stats country/distance Adds GET /api/reservations/upcoming for the dashboard widget, switches travel-stats to the same country source as Atlas (manual + place-derived, ISO codes), and a distance service for flown km. * i18n(dashboard): dashboard keys across locales * feat(dashboard): boarding-pass hero, atlas row, live widgets + modal portal fix Reworked dashboard layout: boarding-pass hero with hover + days-left countdown, atlas stats row with real flags, searchable currency widget, editable timezone widget, new-trip FAB. Modals now portal to document.body to avoid inheriting dashboard-scoped button/font styles. * i18n(dashboard): sync all locales to one key set + German copy-dialog strings Brings every locale's dashboard namespace to the same 149-key set (missing keys backfilled from English) and translates the previously English-only copy-trip dialog into German. * refactor(dashboard): replace hardcoded strings with i18n keys Hero, atlas row, trip cards, filters, currency and timezone widgets now resolve all visible copy through t() instead of hardcoded English/German. * feat(i18n): add Greek translation (#1061) * i18n: complete Turkish (tr) translation (#1075) Fill in the remaining ~2100 UI strings in shared/src/i18n/tr so Turkish matches the English catalog. Brand names, URLs, and technical placeholders are left untranslated by design. * chore: prettier + lint * chore: enforce prettier & lint on shared package * feat: Updated border of map markers to reflect category color. (#1062) * feat(dashboard): mobile layout, glass UI, context bottom nav + OIDC PKCE (#1079) * feat(dashboard): mobile layout, glass tiles, plain-text countdown, place photos - Rework the mobile dashboard: cover hero, separate boarding-pass card, trimmed atlas (trips + days only), stacked widgets - New floating bottom tab bar with a centred context-aware + button (new trip / place / journey / entry depending on the page) - Move profile + notifications into a small top strip on the dashboard - Desktop: glassmorphic tiles (light + dark), neutral dark palette, plain-text countdown module, real place photos in the boarding pass * i18n(dashboard): translate new dashboard keys across all locales Fill the dashboard-rework keys (hero, atlas, fx, tz, upcoming, copy dialog, aria labels, countdown) that were left as English placeholders, plus the new startsIn/aria keys, for all 19 languages. * feat(oidc): send PKCE (S256) in the OIDC login flow The OIDC client now generates a code_verifier per login, sends the S256 code_challenge on the authorize request and the code_verifier on the token exchange. Works whether the provider has PKCE optional or required (fixes login against providers that require PKCE, e.g. Pocket ID). * Migrate TREK 3 to NestJS + React 19 (shared Zod contracts) (#1087) * Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer Brownfield strangler migration of the backend onto NestJS modules (auth, trips, days, places, assignments, packing, todo, budget, reservations, collab, files, photos, journey, share, settings, backup, oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories, tags, notifications, system-notices) served through a per-prefix dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT httpOnly cookie auth, with behavioural parity for every route. Client: React 19 upgrade, "page = wiring container + data hook" pattern across all pages, per-domain Zustand stores bound to @trek/shared contracts, and decomposition of the large components (DayPlanSidebar, PackingListPanel, CollabNotes, FileManager, MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal, BudgetPanel, PlaceFormModal, ...) into focused render units backed by in-file hooks. Apply the shared global request pipeline (helmet/CSP, CORS, HSTS, forced HTTPS, the global MFA policy and request logging) to the NestJS instance as well, so a migrated route is protected identically to the legacy fallback rather than bypassing it. * Finish the NestJS migration — drop the legacy Express app NestJS now serves the whole surface: every /api domain plus the platform routes (uploads, /mcp, the OAuth/MCP SDK + /.well-known metadata and the production SPA fallback). Removed server/src/app.ts, all of server/src/routes/* and the strangler dispatcher; index.ts and the integration suite share a single buildApp() bootstrap so prod and tests can't drift. - Platform/transport routes extracted to nest/platform/platform.routes.ts and mounted before app.init() — Nest's router answers an unmatched request with a 404, so a route registered after init is never reached. The SPA fallback is a NotFoundException filter and the catch-all uses a RegExp (Express 5's path-to-regexp rejects a bare '*'). - New modules: memories (/api/integrations/memories — the Journey gallery's Immich/Synology proxy), addons (GET /api/addons) and the cross-trip GET /api/reservations/upcoming. - TrekExceptionFilter reproduces the old multer / err.statusCode handling so upload rejections keep their 400/413 { error } body and non-ASCII filenames survive (defParamCharset). - addTripToJourney and the MCP get_journey_share_link tool gained the trip-access check they were missing. - Re-pointed the 34 integration tests + the websocket test onto the Nest app; removed the now-meaningless Express-vs-Nest parity tests and a few orphaned client components. * Restore the reset-password rate limit and fix copyTrip reservation links Two correctness/security gaps the NestJS migration introduced: - POST /api/auth/reset-password lost its per-IP rate limiter. Restore it (5 attempts / 15 min on a dedicated bucket, same as the old resetLimiter) so reset tokens can't be brute-forced unthrottled. Covered by AUTH-019. - copyTripById did not copy reservations.end_day_id (a day reference — now remapped through dayMap like day_id) or needs_review, so a duplicated trip lost multi-day transport end-day links and reset the review flag. * Clean up dead code, dedupe helpers, fix the reset-password contract - Remove server exports orphaned by the Express removal: the immich album-link helpers, seven route-only service exports, getFileByIdFull; de-export internal-only helpers (utcSuffix). - De-duplicate verifyTripAccess (9 identical copies -> services/tripAccess.ts) and avatarUrl (3 -> services/avatarUrl.ts); name the bcrypt cost (BCRYPT_COST) and the email regex (EMAIL_REGEX). Public API unchanged. - resetPasswordRequestSchema declared `password`, but the client sends and the service reads `new_password` — rename it so the contract matches and the client types resolve. - Make ATLAS-013 deterministic: stub the admin-1 GeoJSON download instead of fetching ~4600 features from GitHub during the test (it hung the suite). * Make the client typecheck runnable (vitest/vite ambient types) The client had no `typecheck` script and tsc couldn't even start (the baseUrl deprecation errored out, same as server/shared already silence). Add `ignoreDeprecations: "6.0"` to match the other workspaces, a `typecheck` npm script, and a src/vite-env.d.ts referencing vite/client + vitest/globals so tsc knows the test globals (describe/it/expect/vi). This turns ~3600 phantom "Cannot find name" errors into a real, measurable count (~590 actual type errors remain, to be worked down). Type-only; no runtime change. * Derive client domain types from the shared schema contracts Add entity/response Zod schemas to @trek/shared (place, trip, assignment, day, budget, packing, reservation), each matched against the producing server service, and re-export them from client types.ts instead of the hand-written duplicates that had drifted (name/title, amount/total_price, owner_id/user_id, cover_url/cover_image, ...). Updates the call sites and test fixtures the corrected types surfaced; type-only, no runtime behaviour change. * chore(db): log swallowed errors in addon-disable migration + guard against destructive migrations The migration that disables the legacy "memories" addon swallowed any error in an empty catch, as did ~30 other catch blocks in the migration runner (column adds, the journey rebuild, index probes). Replace each silent catch with the existing console.warn('[migrations] ...') log so failures are visible. Control flow is unchanged: every step stays non-fatal, nothing new is thrown. Add a static guardrail test that scans the migration source and fails when a new destructive statement (DROP TABLE / DROP COLUMN / TRUNCATE / DELETE FROM / ALTER ... DROP) appears outside a reviewed allowlist, and when an empty/silent catch block is reintroduced. The existing destructive statements are all legitimate table rebuilds or bounded cleanups and are recorded in the allowlist with a reason. * Re-check SSRF on every redirect hop when resolving short links Replace the one-shot checkSsrf + fetch(redirect:'follow') in the maps and place short-link resolvers with safeFetchFollow, which follows redirects manually and re-runs checkSsrf against the DNS-pinned IP of each hop (max 5). A redirect to an internal/loopback address is now blocked even when the initial URL is public, while legitimate cross-host redirects (goo.gl -> maps.google.com) still resolve. * Reject WebSocket tokens minted before a password change Stamp the user's password_version onto the ephemeral ws token and verify it on connect, closing the socket (4001) when it no longer matches, so a token issued before a password reset can't be replayed. Tokens minted without a version are treated as version 0, matching the JWT pv-claim semantics. * fix(i18n): guard locale key parity and finish the OAuth consent page strings Every non-en locale now exposes the exact same flat key set as en. Keys that had drifted out of sync are backfilled with the English source value (tagged en-fallback) so t() resolves a real string instead of relying on the silent runtime fallback; no existing translation was touched and no key was removed. Add a parity test that imports each aggregated locale bundle and asserts its key set matches en, with a diagnostic listing of any missing/extra keys. This complements the file-level check in shared/scripts by guarding the merged export the app actually serves. Finish internationalising OAuthAuthorizePage: the ~15 remaining hardcoded English chrome strings now go through oauth.authorize.* keys (English source in en, en-fallback placeholders elsewhere). Markup and behaviour are unchanged. * Add semantic theme color tokens to Tailwind Map the CSS theme variables from src/index.css (:root light / .dark dark) to named Tailwind utilities — bg-surface, text-content, border-edge, bg-accent and their variants. This gives components a Tailwind-native target for the theme colors so we can replace inline `style={{ ... 'var(--...)' }}` with utility classes without changing the rendered values. * Surface silent store failures to the user and validate API responses in dev Reservation toggle, todo/packing toggle and budget reorder were swallowing API errors after rolling back, so the user saw the change silently snap back with no explanation. Route those failures through the existing toast channel (new store/notify.ts bridges to window.__addToast, the same channel SystemNoticeBanner uses); the reservation toggle re-throws so ReservationsPanel's own translated toast finally fires. Also wire the existing parseInDev/checkInDev response validation into the maps and notification-test endpoints to catch contract drift in dev. * Migrate static theme inline styles to Tailwind utilities and extract page sub-components Replace the static, color-only inline `style={{ ... 'var(--bg-primary)' ... }}` props with the new semantic Tailwind utilities (bg-surface, text-content, border-edge, ...) wherever the result is byte-identical; dynamic/conditional theme styles and hardcoded status colors are left inline. Extract the Atlas country-search autocomplete, the Admin update banner, and two Journey dialogs into their own presentational components to shrink the oversized page files, keeping behaviour and markup identical. * Remove the unrouted photos page and its dead photo components PhotosPage was never wired into the router and its usePhotos hook read a tripStore photos slice that was never implemented; the Photos gallery, lightbox and upload components were only reachable through it. Per-trip photos now live in the Journey gallery (Immich/Synology). Removed the dead page, hook and components — the live Journey PhotoLightbox is a separate component and stays. * Resolve the remaining client type errors and the trip.title navbar bug Drive the client typecheck to zero without any/ts-ignore: convert the tripId route param to a number once at the page boundary so it matches the numeric props and store actions it feeds, fix trip.name -> trip.title (the wire field is title, so the old read rendered blank in the files/offline views), and tighten the scattered handler-arity, DOM-cast and untyped-payload sites. No runtime behaviour change. * Convert the remaining dynamic and hardcoded inline styles to Tailwind utilities Second styling pass over the components and pages: move conditional theme colors into className ternaries (bg-accent / bg-surface-hover etc.), turn reused CSSProperties constants into className constants, and express static hardcoded hex/rgba colors as Tailwind arbitrary values so the exact rendered colour is preserved. Truly dynamic styling (computed geometry, gradients, multi-part shadows, data-driven colours, the undefined --sidebar/--nav layout vars) stays inline as it cannot be expressed as a static class. Updated three component tests that asserted the old inline active-state styles to assert the equivalent utility class instead. Verified: client typecheck 0, full client suite green, and a live light/dark render check in the dev server confirms the semantic theme tokens resolve correctly (the earlier 'transparent popups' were a stale dev server that pre-dated the tailwind.config token addition, not a code issue). * Add eslint flat-config for client and server and gate typecheck, lint and pages in CI client and server had lint scripts but no eslint config (only shared was linted in CI). Add flat configs mirroring shared's stack (js + typescript-eslint recommended + eslint-config-prettier) plus the client's react-hooks/react-refresh plugins. Pre-existing patterns in this never-linted code (explicit any, require() in the CommonJS server, empty catches, exhaustive-deps) are set to 'warn' rather than 'error' so the gate passes at 0 errors without a repo-wide reformat — these can be ratcheted to errors over time. Wire blocking typecheck + lint + lint:pages steps into the client and server CI jobs (now that both typechecks are clean) and promote the server typecheck from informational to blocking. * Decompose the remaining God Components into hooks, helpers and sub-components FE6: split the oversized page and panel components into thin layout shells plus colocated use<Component> hooks, .constants.ts, .helpers.ts (with tests) and presentational sub-components, following the established 'logic in a hook, render in slices' pattern. Behaviour, markup, classes and effect order are unchanged. Largest reductions: PackingListPanel 1598->42, FileManager 1055->36, AdminPage 1525->167, BudgetPanel 1266->146, JourneyDetailPage 2822->547, PlacesSidebar 945->66, CollabChat 861->106, CollabNotes 1417->532. DayPlanSidebar's drag-and-drop render body was left intact (ref-identity sensitive) and only its toolbar/modals/constants were extracted. * Fix duplicate React keys in the file-assign place list When a place is assigned to the same day more than once it appeared twice in a day's list, so the place-button key={p.id} collided and React warned about duplicate keys. Key by place id + render index so siblings stay unique. Pre-existing in the old FileManager; behaviour unchanged. * Format the shared package and drop an unused import to satisfy the lint gate The i18n and schema changes added code that wasn't prettier-formatted, and place.schema.ts imported categorySchema without using it. Run prettier over shared and remove the import so 'npm run lint' + 'format:check' pass. * Install all workspaces in the server CI job so SWC's native binary is present The server vitest config transforms via unplugin-swc, which needs @swc/core's platform-specific native binary. A workspace-scoped 'npm ci --workspace server' skips that optional dependency, so vitest failed to load the config on the Linux runner. Use a full 'npm ci'. * Re-resolve dependencies with npm install in the server CI job for SWC Full 'npm ci' still skipped @swc/core's Linux native binary because the committed lockfile was generated on Windows and lacks the Linux optional-dep install metadata. 'npm install' re-resolves and fetches the platform-matching binary, which the server's unplugin-swc transform needs to load vitest.config.ts. * Install @swc/core's Linux binary explicitly in the server CI job Neither npm ci nor npm install fetched @swc/core-linux-x64-gnu on the Linux runner because the lockfile was generated on Windows and lacks the Linux optional-dep metadata. Add a step that installs the matching @swc/core-linux-x64-gnu version (no-save, no-lockfile) so unplugin-swc can load the server's vitest config. * Use legacy-peer-deps when installing the SWC Linux binary in CI The explicit @swc/core-linux-x64-gnu install re-resolved the tree and hit the pre-existing lucide-react/react-19 peer conflict that the lockfile was generated around. Add --legacy-peer-deps so the step matches the project's resolution and installs the binary. * Keep the lockfile when installing the SWC binary so other deps stay pinned Dropping --no-package-lock made npm re-resolve the whole tree and upgrade eslint, whose newer recommended config flagged no-useless-assignment as an error in the server lint step. Keep the lockfile so only @swc/core-linux-x64-gnu is added and every other dependency (incl. eslint) stays at its locked version. * Fix a batch of reported bugs: Atlas regions, planner overlays, imports, Safari modals (#1094) * Start the Journey date picker week on Monday (#1078) The Journey entry date picker started the week on Sunday (firstDow = getDay(), headers Su-first) while every other picker (CustomDateTimePicker, VacayCalendar) starts on Monday. Align it: Monday-first leading offset ((getDay()+6)%7) and Mo-first weekday headers. * Fix Taiwan resolving to CN-TW in the Atlas country search (#1049) natural-earth gives Taiwan ISO_A2='CN-TW' (a subdivision-style value) with ADM0_A3='TWN'. The dynamic A2_TO_A3 augmentation added 'CN-TW'->'TWN', which then overwrote the legitimate TWN->TW entry in the reverse map, so Taiwan's country option resolved to 'CN-TW' — unresolvable by Intl.DisplayNames (no name, broken flag, not searchable). Only augment A2_TO_A3 with real 2-letter codes. * Drop empty leftover dateless days when a trip gets a shorter dated range (#1083) generateDays kept all unused dateless placeholder days after switching to an explicit (shorter) date range, so day_count (COUNT(*) FROM days) stayed inflated. Delete the empty leftovers (no assignments/notes/accommodations) like the dateless path already does, while preserving any that still hold content. Adds TRIP-SVC-017. * Render GPX and route overlays once the Mapbox style has loaded (#1036) The GPX and route geojson effects ran before the map 'load' event had attached their sources, so on the first paint they hit the early return and never re-ran. Add mapReady to their dependencies so they fire again the moment the sources exist. * Convert HEIC trip and journey covers to JPEG before upload (#1085) HEIC/HEIF covers coming straight off an iPhone could not be rendered in the preview or stored as a usable image. Route both cover pickers through normalizeImageFile, the same conversion the journal entry editor already uses, so the file becomes a JPEG before it leaves the browser. * Name GPX routes and tracks after their source file so multiple imports stick (#1054) Unnamed routes and tracks all fell back to the same generic 'GPX Route' / 'GPX Track' label, so the name-based import dedup dropped every one after the first - importing several files (or one file with several tracks) only kept a single place. Derive the default name from the source filename with an index suffix when a file holds more than one geometry, thread the filename down through the controller, and let the import modal take more than one file at a time. Adds PLACE-SVC-037/038. * Namespace the modal backdrop class so content blockers stop hiding it (#1027) Generic class names like .modal-backdrop sit on the cosmetic filter lists that content blockers (1Blocker, EasyList Annoyances) ship, and get hidden with display:none. The shared Modal - used by New Trip and Add Place - carried that class, so Safari users running such a blocker saw the modal silently fail to open with no error and no network request. Rename it to .trek-modal-backdrop. * Highlight GB regions by resolving England/Scotland/Wales/NI to finer admin-1 codes (#1067) A zoom-8 reverse geocode of a UK place only resolves to the constituent country (GB-ENG/SCT/WLS/NIR), but Natural Earth's admin-1 polygons for GB are counties and boroughs (GB-LND, GB-MAN, GB-CON, ...). Those four codes match no polygon, so places in England never highlighted in the Atlas while CH/IT/NL/etc. worked. When a GB lookup lands on a constituent country, re-resolve it at a finer zoom where Nominatim exposes the county/borough code the polygons actually carry. Other countries keep the exact zoom-8 behaviour. Adds ATLAS-UNIT-021. * Surface the real place-search error instead of a generic toast (#1092) When a place search or detail lookup fails, the backend already forwards the upstream reason - including descriptive Google Places API messages such as 'Places API (New) has not been used in project ... or it is disabled'. The planner discarded it and always showed 'Place search failed', so a key that is mis-enabled, unbilled, or pointed at the legacy API instead of Places API (New) looked like an unexplained silent failure. Show the server-provided message when present, and stop the Atlas bucket-list search from swallowing its error without a trace. * Await the async cover normalization in the TripFormModal paste test (#1085) handleCoverSelect now normalizes the pasted file before previewing it, so URL.createObjectURL is called a microtask later. The assertion moves into waitFor; a non-HEIC file still passes through unchanged. * fix(pwa): removed orientation from the manifest (#1058) * fix(journey): raise PhotoLightbox z-index above MobileEntryView (#1101) * feat(transport): add bus, taxi, bicycle, ferry and other transport types (#1105) Closes #718. Adds five new transport reservation types alongside the existing flight/train/car/cruise: bus, taxi, bicycle, ferry and a generic 'transport_other' catch-all. The new types are treated as first-class transports everywhere — the transport modal, day plan, route calculation, map overlays, file grouping and the PDF export — and are translated across all 20 locales. A dedicated 'transport_other' value is used for the catch-all so existing 'other' bookings are not reclassified as transport. * feat(reservations): native booking-confirmation import via KDE KItinerary (#1102) * feat(reservations): native booking-confirmation import via KDE KItinerary Adds a two-step preview → confirm flow for importing booking emails, PDFs, PKPass and HTML confirmations. The server invokes the KDE kitinerary-extractor binary, maps JSON-LD schema.org output to TREK reservation shapes, and persists via the existing createReservation pipeline (accommodations, budget, places, WebSocket broadcasts). - NestJS BookingImportModule: preview + confirm endpoints under /api/trips/:tripId/reservations/import/booking{,/confirm} - KitineraryExtractorService: spawns the binary, filters stderr noise, handles QDateTime (@value) timezone-aware datetimes - kitinerary-mapper: FlightReservation, TrainReservation, BusReservation, BoatReservation, LodgingReservation, FoodEstablishmentReservation, RentalCarReservation, EventReservation → typed preview items - BookingImportService: auto-creates place rows; geocodes venues without coordinates via Nominatim (name+address → address → name fallback); resolves day IDs for accommodation linking - BookingImportModal: drag-and-drop multi-file upload, preview cards with type icons, per-item exclude toggle, confirm step - Shared Zod contracts: BookingImportPreviewItem, PreviewResponse, ConfirmRequest, ConfirmResponse — consumed by controller, service, API client and modal - Dockerfile: node:24-trixie-slim runtime; amd64 downloads KDE static binary + locales; arm64 installs libkitinerary-bin + symlinks to fixed path; ENV KITINERARY_EXTRACTOR_PATH set for both arches - /api/health/features exposes { bookingImport: boolean } so the UI hides the Import button when the binary is absent - i18n keys (English), wiki docs, API.md, README one-liner * i18n: add booking import translations for all 19 non-English locales Adds 17 reservations.import.* keys and undo.importBooking to ar, br, cs, de, es, fr, gr, hu, id, it, ja, ko, nl, pl, ru, tr, uk, zh, zh-TW. * chore: enforce i18n parity * docs(wiki): add KItinerary local setup instructions to dev environment guide * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile (#1106) * fix(journey): authorize reads of the journey share link GET /api/journeys/:id/share-link now requires journey access (canAccessJourney), matching the create/delete share-link routes and the get_journey_share_link MCP tool. Returns no link when the caller lacks access to the journey. * feat(costs): rework Budget into Costs — Splitwise-style, multi-currency, mobile Renames the Budget addon to "Costs" (UI only) and reworks it into a Tricount/ Splitwise-style cost tracker: multiple payers per expense, equal split across chosen members, settle-up with persisted history + undo, 12 fixed categories, per-expense currency with live FX conversion to a user-set display currency (Settings -> Display), and locale-correct money formatting. Adds a desktop and a dedicated mobile layout. A migration backfills existing budget items (single payer, split members, currency). Closes #551 (per-expense currency). Also switches the app font to self-hosted Poppins (Geist for secondary subtext), replacing the Google Fonts CDN dependency. * fix(costs): neutral dashboard dark palette + liquid glass, full page width, entry-count badge - Dark mode used a warm oklch palette that read brownish; switch to the neutral zinc tokens used by the dashboard (#121215 bg, #f4f4f5 ink) and add a subtle backdrop-blur glass on cards. - Costs now uses the full available page width on desktop instead of a 1280px cap. - Render the expense count next to the Expenses title as a badge. - Adapt budget/journey unit tests to the new payer-based settlement model and the Costs rename (category default 'other', Costs tab/CostsPanel). * fix(costs): drop the entry-count badge, always show row edit/delete actions Removes the count badge next to the Expenses title and makes the per-row edit/delete actions permanently visible (no longer hover-only) on desktop too. * feat(costs): currency-native money formatting, custom select/date, rename addon to Costs - Format every amount in its own currency convention (symbol position, grouping and decimal separators) regardless of app language, via a currency->locale map (EUR -> '12,00 €', USD -> '$12.00', JPY -> '¥12', ...). Previously Intl used the app locale, so EUR showed the symbol in front under an English UI. - Use TREK's CustomSelect (searchable, with symbols) and CustomDatePicker in the add/edit expense modal instead of the native <select>/<input type=date>. - Rename the 'Budget Planner' add-on to 'Costs' in the admin list (display only; id/tables/permissions/MCP stay 'budget') via seed + a migration for existing DBs. * feat(auth): configurable session duration via SESSION_DURATION Adds a SESSION_DURATION env var (ms-style strings: 1h, 7d, 30d, ...) controlling how long a session stays valid before re-login. It drives both the trek_session JWT exp claim and the cookie maxAge from one source, so they never drift. Invalid values warn at startup and fall back to the default (24h — unchanged). The MFA challenge token and MCP OAuth tokens keep their own TTL. Implements the request from discussion #946. Documented in the env-var wiki page, .env.example and docker-compose.yml. * feat: Passkey (WebAuthn) login (#1111) * feat(auth): passkey (WebAuthn) login — server endpoints, schema + admin toggle Add @simplewebauthn/server registration and primary (discoverable) login ceremonies under /api/auth/passkey, a webauthn_credentials + single-use webauthn_challenges schema (migration), the instance-wide passkey_login toggle (default off) enforced before auth by a guard, and require_mfa satisfaction via a verified passkey. RP ID/origin come only from server config (webauthn_rp_id/origins -> APP_URL), never request headers. * feat(auth): passkey enrolment, login button + admin settings UI PasskeysSection in account settings (add/rename/remove with a current-password step-up), a 'Sign in with a passkey' button on the login page, the admin enable + RP-ID/origins controls, and a per-user admin reset action. * i18n(auth): passkey strings across all locales Add login/settings/admin passkey keys to en and all 19 translated locales. * chore: update kitinerary version * Backend/frontend hardening & consistency cleanups (#1113) * refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp) * feat: optimize routes around accommodation, confirm note deletions (#1123) Optimize day routes around the accommodation When a day has an accommodation set, the route optimizer now treats it as the day's home base: it optimizes a loop that leaves the hotel and returns to it, so the stop nearest the hotel comes first. On a transfer day - checking out of one hotel and into another - the route runs from the first hotel to the second instead. The optimizer also gained a 2-opt pass on top of the nearest-neighbor ordering, which removes the crossings the greedy pass used to leave behind. A new display setting ("optimize route from accommodation", on by default) lets you turn the anchoring off. Confirm before deleting notes Deleting a plan note or a collab note now asks for confirmation first. On phones and tablets the edit and delete icons sit close together and were easy to mis-tap, which deleted notes with no way back. * fix: miscellaneous bug fixes (#1139) * fix(share): serve place thumbnails in shared trip links (#1100) Google-sourced place photos are stored as image_url pointing at the JWT-guarded /api/maps/place-photo/:placeId/bytes endpoint, so they 401 for an unauthenticated shared-trip viewer and render as broken images. Rewrite place image_url values in the shared payload to a public, token-scoped proxy (/api/shared/:token/place-photo/:placeId/bytes) and add an unguarded SharedController route that validates the token and that the place belongs to its trip before streaming the cached bytes. Mirrors the existing JourneyPublicController precedent. No client changes needed. * fix(atlas): replace Natural Earth with geoBoundaries for up-to-date regions (#1119) Atlas sourced country and sub-national boundaries from Natural Earth's GitHub `master` at runtime. That data is stale (e.g. it still shows Norway's pre-2020 counties such as Oppland/Hordaland) and depicts some contested territory in unwanted ways (nvkelso/natural-earth-vector#391), so Natural Earth is dropped entirely. - Country borders (admin0) now come from the geoBoundaries CGAZ composite; sub-national regions (admin1) from per-country gbOpen, which carries ISO 3166-2 codes. A new script (server/scripts/build-atlas-geo.mjs) normalizes and quantizes them into committed gzipped bundles under server/assets/atlas, read server-side at runtime (no network at boot, no GitHub CSP allowlist entry). - New GET /addons/atlas/countries/geo serves the country layer; the client fetches it from the API instead of GitHub. - A migration reconciles manually-marked visited_regions against the new bundle (valid code -> keep; region name still matches -> re-code; curated merge crosswalk for renamed reforms; else leave intact), with UNIQUE-safe dedup. bucket_list and visited_countries hold only invariant alpha-2 country codes, so they are untouched. - Attribution added (NOTICE.md + README) per geoBoundaries CC BY 4.0. Closes #1119 * fix(packing): make templates admin-only to create, usable by members Creating a packing-list template was gated only by trip access, so any trip member could create one from the Lists feature, while applying a template silently failed for non-admins because the apply dropdown was populated from the AdminGuard-protected /api/admin/packing-templates endpoint. - save-as-template now returns 403 for non-admins; the Save-as-Template button is hidden unless the user is an admin (both the TripPlanner toolbar and the inline packing header). - add member-accessible GET /api/trips/:tripId/packing/templates so the apply dropdown lists templates for any trip member; client fetches from it instead of the admin endpoint. Closes #1120 Closes #1121 * fix(packing): show bag tracking to non-admin members The global Bag Tracking toggle was only readable via the admin-gated GET /api/admin/bag-tracking, so non-admin trip members got 403 and the weight fields, bag circles, and BAGS sidebar never rendered (#1124). Surface the flag through the already-authenticated GET /api/addons (loaded into the client addon store on app start for every user); the packing hook reads it from the store instead of the admin endpoint. The admin write path stays admin-gated and unchanged. * Fix a batch of reported bugs (#1145) * fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137) * fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129) * fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119) * fix(unraid): point the template at a PNG icon Unraid can render (#1073) * fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069) * fix(map): centre the selected pin in the visible map area above the bottom panel (#1125) * fix(pdf): render persisted place-photo proxy URLs as images (#1130) * fix(planner): show the selected place category in the edit form (#1134) * fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132) * Support multi-leg (layover) flights (#1146) * feat(transport): support multi-leg (layover) flights in the booking form A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER -> HND) instead of a single departure/arrival pair. The route is entered as a list of waypoints with a '+ add stop' button; each stop carries its own arrival and departure time plus the airline/flight number of the segment leaving it, while the whole booking keeps one price. Stored without a schema change: the existing reservation_endpoints rows carry the ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/ flight_number) mirrors the first and last leg, so a single-leg flight persists exactly as before and legacy readers keep working. * feat(planner): show each flight leg as its own day-plan entry, ordered by time A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA -> HND), each on its own day with its own times, instead of a single span. Each leg is an addressable slot (reservation id + leg index) so places and notes can be dropped into the layover gap between legs; the per-leg position is persisted in metadata.legs[i].day_positions and survives a reload. Day-plan items are now ordered chronologically: anything with a time (a place's time, a flight leg, a timed note) sorts by that time, and untimed items inherit the time of the item before them so they stay where they were placed. * feat(planner): show the full multi-stop route in the bookings panel The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of just the first and last airport. * feat(map): draw multi-leg flights as connected legs with a marker per airport Both the Leaflet and Mapbox overlays now render a flight over all its waypoints: one great-circle arc per leg and a marker at every airport, with the label showing the full route and the summed distance. A single-leg flight is unchanged. Also drops the floating stats badge that was drawn on transport arcs. * fix(map): centre a clicked place above the bottom inspector panel Selecting a place panned/flew it to the dead centre of the screen, where it sat behind the detail card. Both overlays now bias the target into the visible area above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox passes the padding to flyTo). * feat: show the full multi-stop flight route in PDF and calendar export The PDF day list and the ICS export now render the whole route (FRA → BER → HND) for a multi-leg flight instead of just the first and last airport, falling back to the flat metadata for single-leg flights. The ICS keeps a single event per booking. * feat(import): group connecting flight legs into one multi-leg booking When a booking confirmation contains several flight legs sharing a PNR that connect at the same airport with a short layover (under 24h), they are now imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs) instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two separate bookings, and a single flight is unchanged. * i18n: translate the new flight-route strings into all languages * i18n: translate the Costs page into every language The Budget → Costs rework left the new costs.* strings untranslated in every non-English locale (they fell back to English). Translate them across all supported languages. * Revert "fix(map): centre a clicked place above the bottom inspector panel" This reverts commit0936103f04. * Explore places on the map, planner route fixes, and instance-wide Mapbox (#1147) * feat(maps): add an OSM POI search endpoint (category within a viewport) New /api/maps/pois queries OpenStreetMap via Overpass for places of a category (restaurants, cafes, hotels, sights, …) inside a bounding box. OSM-only by design — it never calls Google, even when a Google key is configured. * feat(map): explore nearby places on the trip map (OSM category pill) A floating, icon-only pill over the planner map lets you toggle a POI category and see those OpenStreetMap places in the current view; clicking a marker opens the add-place form pre-filled (name, address, website, phone). Single-select with a 'search this area' action after the map moves. Renders on both the Leaflet and Mapbox maps, and can be turned off in settings (discussion #841). * fix(planner): anchor timed places when optimising and route transports by location - The day optimiser no longer reshuffles places that have a set time — they stay anchored to their time, like locked places. - The route now uses a transport's departure/arrival location as a waypoint when it has one (e.g. a flight's airport), instead of breaking the route at every booking; transports without a location are ignored for routing but still show their leg's distance/duration under the booking. * feat(admin): instance-wide Mapbox defaults in default user settings Admins can set a shared Mapbox token (plus style, 3D and quality) as instance defaults, so the whole instance can use Mapbox without each user pasting their own key. Users without their own value inherit it via the existing admin-defaults merge; the shared token is stored encrypted (discussion #920). * Reorder whole days and insert a day (#589) (#1148) * feat(days): reorder whole days and insert a day at a position Adds reorderDays + insertDay to the day service and a PUT /days/reorder route (plus an optional position on create). Day rows stay stable so a day's assignments, notes, bookings and accommodations ride along by id; on a dated trip the calendar dates stay pinned to their slots while the content moves across them, and each booking's date is re-stamped onto its day's new date (time-of-day preserved) so day_id stays consistent. Renumbering uses the two-phase write to avoid the UNIQUE(trip_id, day_number) collision, and a move that would invert an accommodation's check-in/out span is rejected. * feat(planner): reorder days from a toolbar popup, and add days A new toolbar button opens a popup listing the days; drag a row by its grip or use the up/down arrows to reorder, and add a day from there. Reorders apply optimistically with rollback and sync over WebSocket; the day headers are left untouched, so the existing place drop-targets are unaffected. * i18n: add day-reorder strings across all languages * Map/planner/dashboard polish and small community features (#1155) * feat(planner): reorder days in a modal instead of a dropdown The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged. * feat(map): explore reliability, Mapbox popups + compass, region-biased search POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out. Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north. /api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result. * feat(dashboard): list-view and mobile polish Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts. Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar. * feat: small community-requested options Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields. * test(shared): bump day-note subtitle limit assertion to 250 * test: align specs with the new search param order and archive label Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call. * fix(packing): add more bag colors so sub-bags stop repeating (#1156) The auto-assigned bag palette only had 8 colors, so the 9th bag reused the first one. Double it to 16 (keeping the existing 8 and their order) and keep the server and client lists in sync - both cycle BAG_COLORS[count % length]. * fix(packing): respect per-item quantity in bulk import (#1157) * AirTrail integration: import flights & two-way sync (#214) (#1158) * feat(admin): register AirTrail as an integration addon Off by default; toggle lives in Admin -> Addons with a Plane icon. The per-user connection (URL + API key) follows in integration settings. * feat(integrations): add per-user AirTrail connection Settings -> Integrations gains an AirTrail section: instance URL + Bearer API key (encrypted at rest via apiKeyCrypto), a self-signed-TLS opt-in and a test-connection check. Served by a small Nest controller under /api/integrations/airtrail, gated on the airtrail addon and SSRF-guarded. The key is per-user, so it only ever returns that user's own flights. * feat(transport): import flights from AirTrail Adds an AirTrail Import button next to Manual Transport that lists the user's AirTrail flights and highlights the ones inside the trip dates. Selected flights become reservations linked to their AirTrail origin (external_* columns), deduped against flights already in the trip, then broadcast to every member. The mapping resolves airports, airport-local times and flight metadata; the linkage is what the two-way sync rides on. * feat(transport): badge AirTrail-linked flights as synced Linked reservations show an 'AirTrail synced' badge, or 'no longer synced' once the flight is gone from AirTrail. * feat(transport): keep TREK and AirTrail flights in sync both ways A scheduled poll reconciles each connected owner's flights: field edits (detected by snapshot hash, since AirTrail has no updated_at) flow into the linked reservation and broadcast live; a flight deleted in AirTrail keeps the TREK row but stops syncing. Editing a linked flight in TREK pushes back to AirTrail under the importer's credentials, preserving the existing seat manifest; if the owner disconnected the link detaches so the poll can't revert the local edit. Deleting in TREK never touches AirTrail. * i18n(airtrail): add AirTrail strings across all locales * test(airtrail): cover flight mapping, timezones and snapshot hashing * fix(airtrail): reduce airline/aircraft objects to codes The flight list/get response returns airline and aircraft as joined objects ({icao, iata, name, ...}), not bare codes. Mapping them straight through produced '[object Object]' titles and stored objects in metadata, which crashed reservation rendering. Extract the ICAO/IATA code instead, and title flights by their flight number. * fix(airtrail): clear error on non-JSON responses, tolerate /api in URL A misconfigured instance URL made AirTrail serve its SPA/login HTML, and the raw JSON.parse failure surfaced as 'Unexpected token <'. Surface an actionable message instead, and strip a pasted trailing /api so the base URL still resolves. * feat(transport): sync AirTrail edits on trip open, not just on the poll Add a per-user on-demand sync (POST /integrations/airtrail/sync) triggered when a connected user opens a trip, so AirTrail-side edits appear right away instead of waiting up to a full poll cycle. Lower the background poll from 15 to 5 minutes as a safety net. * fix(transport): refresh imported AirTrail flights without a reload loadTrip doesn't fetch reservations, so a freshly imported flight only appeared after a full page reload — use loadReservations instead. Also show flight dates in the user's locale format (e.g. 13.06.2026) rather than the raw ISO string. * style(settings): align AirTrail connection with the photo-provider layout Match the Immich section: stacked URL/key fields, a ToggleSwitch for self-signed TLS, and a Save / Test-connection row with a status badge. * feat(transport): add a seat field when editing flights The transport editor only offered a seat field for trains; flights had none even though imports store metadata.seat. Show and persist a seat for flights too. * style(transport): match the AirTrail button height to Manual Transport * feat(transport): put the flight seat next to flight number and sync it to AirTrail Move the seat from a standalone row to the per-leg flight details (beside the flight number), stored per leg in metadata.legs[].seat with the first leg mirrored to metadata.seat. On push, set the seat number on the user's own AirTrail seat (the one with a userId), leaving co-passengers untouched; import/poll read that same seat back. * refactor(planner): move the AirTrail trip-open sync into useTripPlanner Page containers must not own state/effects (lint:pages). Same logic, relocated from the page into its data hook. * test(db): pin the region-reconciliation test to its schema version The test re-ran 'the last migration' assuming the reconciliation is last; it no longer is once later migrations are appended. Pin to version 135 and re-run from there (the appended migrations are idempotent). * Various fixes: 2FA autofocus, viewer-timezone times, duplicate place guard (#1159) * fix(auth): autofocus the 2FA code input when the MFA step appears (#767) * fix(notifications): show notification and admin times in the viewer timezone (#1149) SQLite CURRENT_TIMESTAMP is UTC but the string has no Z, so the client parsed it as local time. Normalize in-app notification created_at to ISO-UTC, and stop forcing the admin user table to render in the server timezone. * fix(places): warn before adding a duplicate place (#1152) Manually adding a place did not check the existing pool, so the same POI could land in Unplanned twice. Flag a likely duplicate by Google Place ID, name or near-identical coordinates and require a confirming second click to add anyway. * fix(planner): make route tools reachable in mobile day plan sheet (#1142) * wiki: update dev env * wiki: small precision in dev env * fix(planner): make route tools reachable in mobile day plan sheet On mobile, selecting a day closes the plan sheet immediately, so the route tools footer (Route toggle / Optimize / routing profile) - gated on the selected day - was never reachable. Desktop was unaffected. - Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set, route tools render on any expanded day with 2+ assigned places - Make handleOptimize accept an explicit dayId (defaulting to selectedDayId, preserving desktop behavior) - Keep the distance/duration pill gated on the selected day, since routeInfo belongs to the selected day's calculated route - Enable the prop on the mobile plan sheet in TripPlannerPage * fix(planner): correct route-tools prop doc and dev-environment wiki - Reword the showRouteToolsWhenExpanded JSDoc to list the controls the footer actually renders (Route toggle / Optimize / travel profile); there is no "Open in Google Maps" action in that block. - Wiki: drop the non-existent server test:parity script, document the real shared i18n:parity checks, and fix the i18n note (the translation layer already lives in @trek/shared, it is not "upcoming"). --------- Co-authored-by: jubnl <jgunther021@gmail.com> Co-authored-by: Maurice <mauriceboe@icloud.com> * feat(places): enrich list-imported places via the Places API (#886) (#1161) * feat(places): enrich list-imported places via the Places API (#886) Google/Naver list imports only carry a name and coordinates, so the places open as bare pins — the Maps tab jumps to coordinates, with no photo, address or open/closed. Add an opt-in "Enrich places via Google" toggle to the list-import dialog, shown only when a Google Maps key is configured. When enabled, after the (fast, unchanged) import the server runs a background pass that re-resolves each place by name — biased to and validated against the imported coordinates so a common-name search cannot overwrite the wrong place — and fills the empty address/website/phone/photo columns plus the resolved google_place_id, pushing each row over the live sync. Opening hours and the proper Maps link then work on demand from the stored id. Enrichment only fills empty fields, runs detached so a long list never blocks the import, and no-ops when no key is configured. * fix(places): use the ToggleSwitch component for the enrich toggle Match the rest of the app — the import-enrichment opt-in used a raw checkbox; swap it for the shared ToggleSwitch (text left, switch right) like the settings toggles. * fix(maps): bound place-photo cache growth (Wikimedia + Google) (#1174) The place-photo cache (uploads/photos/google) grew unbounded: a Wikimedia geosearch path cached full-res originals despite requesting a 400px thumb, the writer applied no size guard, nothing reclaimed orphaned files, and backups archived the whole re-derivable cache verbatim. - Prefer the scaled `thumburl` over the full-res `info.url` in the Commons geosearch fallback. - Downscale any cached image to <=800px JPEG via the existing jimp dep, with a safe fallback to the original bytes on decode failure. - Add sweepOrphans() (orphaned meta rows + stray files) wired into the scheduler (startup + nightly), and removeIfUnreferenced() called on place delete for prompt reclamation. - Exclude the re-derivable photo/trek caches from backups; restores self-heal as the cache dirs are recreated at startup. * fix(sync): remap temp ids, prevent id collisions, surface failed mutations (#1175) Closes three offline BLOCKERs from the PWA audit: - B1: offline edits/deletes of an offline-created entity were lost. The negative temp id was baked into the PUT/DELETE url and never rewritten after the CREATE returned a real id, so dependents 404'd and were dropped. Dependents now carry a {id} placeholder + tempEntityId; flush builds a tempId->realId map and durably rewrites still-queued dependents on CREATE success (survives flush boundaries / reloads). - B2: tempId = -(Date.now()) collided within a millisecond, overwriting an optimistic row. Replaced with a monotonic nextTempId() minter. - B3: any 4xx marked the mutation failed with no rollback and no signal, and the badge ignored failed rows. Terminal failures now roll back the phantom optimistic CREATE; 401/408/425/429 are treated as retryable; failedCount() is surfaced in OfflineBanner (red pill) and OfflineTab. * fix(maps): make offline tiles cover real trips (cap coherence + zoom-clamp) (#1177) Closes BLOCKER B5 — the offline map was blank for most real trips: - The Workbox 'map-tiles' cache held only 1000 entries while the prefetcher budgeted ~3413, so prefetched tiles were evicted on arrival. Both caps are now a coherent 12288 (~180 MB), kept in sync with cross-referencing comments. - prefetchTilesForTrip skipped a trip entirely when its all-zooms estimate exceeded the cap, so region/road-trip bboxes got no tiles. Removed the all-or-nothing guard; prefetchTiles already fills zooms low→high and stops at the budget, so large trips now cache the zooms that fit instead of nothing. * fix(security): stop cross-user offline data leak on shared devices (#1176) Closes BLOCKER B4 — three reinforcing paths could serve one account's cached data to the next user on a shared device: - The Workbox 'api-data' cache keyed trip/user-scoped GETs by URL only (cookie-blind). Changed to NetworkOnly; offline reads come from the per-user IndexedDB cache via the repo layer instead. - IndexedDB had no per-user scoping. The Dexie connection is now scoped per user (trek-offline-u<id>) behind a Proxy so the ~19 importers keep a stable binding; login opens the user DB, logout deletes it and returns to the anonymous DB. - logout() was fire-and-forget and racy: background flush/syncAll could re-seed the DB after the wipe. It is now async and ordered — close an auth gate, unregister sync triggers, disconnect, clear caches, delete the user DB — and flush()/syncAll() bail when the gate is closed. * fix(db): scope, evict, and cap the offline blob cache (H3) (#1178) Blob cache previously leaked forever: clearTripData omitted it, entries had no trip discriminator, and there was no size/count bound, so file blobs survived trip eviction and could starve the map-tile cache for quota. - BlobCacheEntry gains tripId + bytes; Dexie v3 adds a tripId index with a backfill upgrade (legacy rows -> tripId -1, bytes from blob.size) - clearTripData purges the trip's blobs in-transaction - enforceBlobBudget() evicts oldest-by-cachedAt past 200 entries / 100 MB - tripSyncManager threads tripId/bytes into puts and enforces the budget * fix(repo): fall back to Dexie when a network read fails (H2) (#1179) Repos gated reads on raw navigator.onLine and the online branch had no try/catch, so a captive portal or connected-but-no-internet (navigator.onLine lying "true") threw a network error instead of serving the good cached copy — blanking the trip even though Dexie held it. - new onlineThenCache(onlineFn, cacheFn) helper: reads the cache when offline, and on a network-level failure (Axios error with no HTTP response). A genuine HTTP error (4xx/5xx — the server responded) is rethrown so callers still set error state / navigate, not masked by a stale cache. - gates only on navigator.onLine, NOT the connectivity probe: the probe is a coarse global flag and one failed health check would otherwise divert every read to the (possibly empty) cache even when the request would succeed. - every repo list/get read path routed through it (reads only — writes still go through the mutation queue so failures surface) - tests: captive-portal fallback, HTTP-error rethrow, non-Axios rethrow * fix(store): reset and uniformly hydrate trip-scoped slices in loadTrip (H4, H5) (#1180) loadTrip only replaced the first slice group, so budget/reservations/files from a previous trip stayed visible after switching trips (data exposure on a shared screen). Those three also loaded via separate tab-gated effects, so they never hydrated offline for an unopened tab. - resetTrip() clears every trip-scoped slice (keeps global tags/categories) and runs at the top of loadTrip, so a switch can't leak the prior trip's data - loadTrip now hydrates budget/reservations/files through their repos alongside the rest (non-fatal catches), making offline hydration uniform - useTripPlanner drops the redundant loadFiles + reservations/budget effects; tab-gated lazy reloads stay as on-demand refresh - tests: cross-trip no-leak, uniform hydration, resetTrip * fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181) setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie re-seeded but the open trip's Zustand store was never refreshed — a collaborator's edits made while we were offline didn't appear until navigating away and back. - new tripStore.hydrateActiveTrip(): silent refresh of the active trip's collaborative state (days/places/packing/todo/budget/reservations/files), no resetTrip and no isLoading toggle so there's no splash on reconnect - syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook first) and re-hydrates open trips after the online-event syncAll; cleared on unregister - websocket exposes getActiveTrips() for the online-event path - tests: refetch wiring + ordering, silent hydrate without reset/splash * fix(server): lengthen idempotency key TTL to survive multi-day offline (H6) (#1182) The nightly cleanup deleted idempotency keys older than 24h. The TREK client replays queued mutations with their X-Idempotency-Key on reconnect, so a device offline longer than a day had its keys GC'd before it returned — the replayed POST was then treated as new and created a duplicate. - raise the TTL to 30 days (DEFAULT_IDEMPOTENCY_TTL_SECONDS), overridable via IDEMPOTENCY_TTL_SECONDS - extract purgeExpiredIdempotencyKeys(now, ttl, db) (mirrors cleanupOldBackups) with an injectable db, and have the cron job call it - tests: 30-day default eviction, 25-day key retained (was dropped at 24h), env override H7 (exactly-once across the lost-response window) is deferred: a correct fix must store the response in the same DB transaction as the entity write. Doing it in the generic interceptor (reserve-before-handler) cannot store the real response body for the crash case, which would break the client's temp->real id remapping on replay (mutationQueue.flush relies on the entity in the body). It needs a per-service change and is tracked separately. * fix(realtime): correct assignment:created echo dedup (H11) (#1183) When X-Idempotency/X-Socket-Id let an own-echo through, the assignment:created dedup had two bugs: it keyed on place id, so (1) a legitimate second assignment of a place already on the day was silently dropped, and (2) the temp-version reconciliation matched place?.id === placeId, letting undefined === undefined collapse place-less rows onto each other. - dedup now keys on assignment id (exact-id duplicate -> no-op) - temp (negative-id) optimistic rows are reconciled only when a real placeId matches, replacing just that row; a sibling temp of another place is untouched - everything else appends, including a genuine 2nd assignment of the same place - tests: 2nd-of-same-place kept, correct temp picked among siblings, place-less rows don't collapse Note: the broader own-echo suppression relies on X-Socket-Id being sent; this fixes the client-side fallback when an echo slips through. * fix(pwa): persist offline storage + Mapbox offline policy (H8, H9) (#1184) H8: prefetched tiles and file blobs could be evicted under storage pressure (worsened by opaque tile responses inflating the quota ~7MB each), blanking the offline map right when a traveler needs it. Request persistent storage at app init so the browser exempts our caches from eviction. We deliberately keep tile requests no-cors (a cors switch would break self-hosted/custom tile providers without CORS headers), so persistence is the safe mitigation rather than de-opaquing responses. H9: Mapbox GL users had no offline map at all — no runtimeCaching matched the Mapbox hosts. Add a StaleWhileRevalidate rule for api.mapbox.com / *.tiles.mapbox.com so visited areas are available offline (best-effort; full pre-download still requires the Leaflet renderer, now documented). - new sync/persistentStorage.ts requestPersistentStorage(), called from main.tsx - vite.config: mapbox-tiles SW cache rule - MapViewAuto / tilePrefetcher comments document the offline-maps policy - tests for the persist helper (granted / already-persisted / absent / rejects) * ci(security): only fail Docker Scout on fixable CVEs Add only-fixed so the scan no longer fails on vulnerabilities with no upstream fix available (e.g. base-image OS packages), and only flags actionable, fixable findings. * build(docker): rebuild gosu with a current Go toolchain Debian's apt gosu ships an old Go stdlib that the image CVE scan flags (1 critical + several high, all in golang/stdlib). Build gosu from source with a current Go toolchain and copy the static binary in instead; the runtime behaviour is unchanged — gosu still drops root to node at startup. * build(deps): bump tsx's esbuild to 0.28.1 (GHSA-gv7w-rqvm-qjhr) The production image's last image-scan finding was esbuild 0.28.0, pulled in transitively by tsx. Pin tsx's esbuild to 0.28.1 (within tsx's ~0.28.0 range) to clear GHSA-gv7w-rqvm-qjhr. Lockfile-only; no runtime change. * feat(auth): add "Remember me" checkbox to extend session lifetime (#1189) Adds a "Remember me" checkbox to the login form (single responsive page, covers mobile + desktop). Unchecked (default) issues the existing SESSION_DURATION JWT with a browser-session cookie (no maxAge); checked issues a longer-lived JWT plus a persistent cookie sized by the new SESSION_DURATION_REMEMBER env var (default 30d). The choice is threaded through the MFA verify leg so it survives the step-up. Register/demo logins keep their current persistent behaviour. * chore(ssrf): include lookup error code in error message * fix(backup): restore from Docker, fail-fast on shadowed /app, bundle encryption key (#1193) (#1197) * fix(backup): restore uploads through symlinked dir and bundle encryption key (#1193) Restoring a backup inside Docker threw ERR_FS_CP_DIR_TO_NON_DIR because /app/server/uploads is a symlink to the mounted /app/uploads volume and cpSync (dereference:false) refuses to overwrite the symlink node with a directory. The DB was swapped before this failing copy, so users saw restored data but missing upload files (trip covers). Resolve the symlink with realpathSync before copying so the merge targets the real directory; no-op on a plain dir, so non-Docker behavior is unchanged. Also bundle the at-rest encryption key (data/.encryption_key) into the backup so a restore onto a different install can decrypt stored secrets (API keys, MFA, SMTP/OIDC). Skipped when ENCRYPTION_KEY is provided via env (the file is not the source of truth then). On restore the key is swapped back if the archive carries one; a restart is required for the in-memory key to take effect. * fix(docker): fail fast when a volume shadows /app (#1193) Mounting an old volume at /app hides the image's node_modules and dist, so startup crashed with a cryptic "Cannot find module 'tsconfig-paths/register'". Add a CMD preflight that detects the missing app files and exits with actionable guidance. Document in the README that only /app/data and /app/uploads should be mounted, never /app. * fix: ssrf test * fix(places): fall back to search when autocomplete details lookup fails (#1192) (#1198) Clicking an auto-suggest dropdown item did a second /maps/details lookup that could fail (details kill-switch off, an overloaded OSM Overpass mirror behind a proxy, or any upstream error), dead-ending on "Place search failed" while the search button stayed reliable. handleSelectSuggestion now treats a missing or coordinate-less details result (or a thrown error) as a miss and falls back to the text-search path the search button uses, applying the first result. The error toast only fires if the fallback also returns nothing. Adds tests for the previously untested suggestion-click path. * fix(planner): scroll long place description/notes on mobile (#1195) (#1199) The place details card (PlaceInspector) clipped long description/notes with no way to scroll. The content area is a flex column whose children (description/notes) had the default flex-shrink: 1, so once the card hit its maxHeight cap they compressed to fit and their overflow:hidden clipped the text instead of overflowing into a scroll region. - Make the content area a bounded scroll region (flex: 1 1 auto, minHeight: 0, overflowY: auto, momentum + overscroll containment). - Pin description/notes with flexShrink: 0 so they keep natural height and the card overflows into the scroll instead of clipping. - Pin header/footer with flexShrink: 0 so they stay fixed while scrolling. - Add wordBreak/overflowWrap to the description div to fix horizontal clip. * Day plan: hotel travel times at start/end + login toggle polish (#1206) * fix(login): use the shared toggle for the stay-signed-in option * feat(planner): show hotel travel times at the start and end of a day * fix(login): give the stay-signed-in toggle an accessible name and fix its test * fix(trips): keep the day-count field empty when cleared and validate it (#1204) (#1207) * docs(readme): refresh dashboard, costs and trip screenshots (#1208) * docs(readme): refresh dashboard, costs and trip screenshots * docs(readme): correct outdated info (React 19, NestJS, 20 languages, Costs rename, passkeys, AirTrail, notifications) * chore: update all dependencies (#1209) * chore: update all dependencies * chore: remove lint errors * fix(client): restore typecheck after dependency bump vitest 4 types vi.fn() as Mock<Procedure | Constructable>, which no longer assigns to the strictly-typed onUpdate prop; type the mock explicitly. TS6 + the new transitive @types/node 25 stopped auto- including node builtin module types, so import('node:buffer') failed; add @types/node as a direct client devDependency and a scoped node type reference in the one test that needs it. * test: fix constructor mocks for vitest 4 Reflect.construct semantics vitest 4 resolves new-invoked mocks via Reflect.construct, which rejects arrow-function implementations (including mockReturnValue sugar) as non-constructable. Convert mapbox-gl and better-sqlite3 mocks that the code instantiates with new to regular function implementations. * fix(planner): only route to multi-day transport endpoints on their pickup/drop-off days (#1210) (#1212) * chore: move to Frankfurter API for exchange rate (#1214) * Restore nest coverage to >=80% after the #1209 dep bump (istanbul provider + branch tests) (#1213) * fix(server): set oxc:false in vitest so the SWC transform survives the Vite 8 bump * fix(server): switch coverage to the istanbul provider (v8 under-reports branches on Vite 8 + Vitest 4) * test(nest): cover controller/service branches to clear the 80% coverage gate * fix(planner): correct transfer-day hotel legs and connect them to transports (#1215) When you change hotels on a day, the morning bookend leg showed the hotel you check into instead of the one you slept in whenever the morning stay didn't end exactly on that day — both bookends collapsed onto the arriving hotel. The morning hotel is now picked by "checked in earlier and still in range" rather than "checks out today", which also fixes the route optimizer's start anchor for the same case. The bookend legs now connect to the first/last located waypoint of the day — a place or a transport endpoint (a car return, a taxi or train arrival) — so the hotel-to-transport drives are included too. * feat(transports): add kitinerary import-from-file button to Transports tab * docs(config): document SESSION_DURATION_REMEMBER across deployment artifacts Add SESSION_DURATION_REMEMBER to docker-compose, .env.example, README env table, Helm chart (values + configmap passthrough), the Unraid template, and the Unraid install guide. Where the base SESSION_DURATION was also absent (README, charts, Unraid) add the pair so the Remember-me variable has context. --------- Co-authored-by: gzor <risenbrowser@web.de> Co-authored-by: ppuassi <34529179+ppuassi@users.noreply.github.com> Co-authored-by: sss3978 <106522699+soma3978@users.noreply.github.com> Co-authored-by: SkyLostTR <onurluerin@gmail.com> Co-authored-by: Julien G. <66769052+jubnl@users.noreply.github.com> Co-authored-by: Dimitris Kafetzis <39215021+Dkafetzis@users.noreply.github.com> Co-authored-by: Ahmet Yılmaz <70577707+sharkpaw@users.noreply.github.com> Co-authored-by: jubnl <jgunther021@gmail.com> Co-authored-by: jufy111 <40817638+jufy111@users.noreply.github.com> Co-authored-by: Larinel <bodink7@gmail.com> Co-authored-by: rossanorbr <48014819+rossanorbr@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* GET /api/addons e2e — exercises the AddonsController through the real
|
||||
* JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig
|
||||
* are mocked; the addons/photo_providers/photo_provider_fields reads run against
|
||||
* the temp db. Asserts the byte-identical body the legacy inline handler produced.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`);
|
||||
tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT,
|
||||
label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER,
|
||||
settings_key TEXT, payload_key TEXT, sort_order INTEGER);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
|
||||
const { getCollabFeatures, getBagTracking, getPhotoProviderConfig } = vi.hoisted(() => ({
|
||||
getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })),
|
||||
getBagTracking: vi.fn(() => ({ enabled: true })),
|
||||
getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })),
|
||||
}));
|
||||
vi.mock('../../src/services/adminService', () => ({ getCollabFeatures, getBagTracking }));
|
||||
vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsModule } from '../../src/nest/addons/addons.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run();
|
||||
db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run();
|
||||
db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run();
|
||||
db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order)
|
||||
VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run();
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/addons')).status).toBe(401);
|
||||
});
|
||||
|
||||
// Session 1 is a default-role ('user') account — i.e. a non-admin. Asserting the
|
||||
// global bagTracking flag here is present is the #1124 regression guard: reading the
|
||||
// toggle must not require admin.
|
||||
it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => {
|
||||
const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true },
|
||||
bagTracking: true,
|
||||
addons: [
|
||||
{ id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true },
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
icon: 'Image',
|
||||
enabled: true,
|
||||
config: { url: 'https://immich.example' },
|
||||
fields: [
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'Base URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://...',
|
||||
hint: null,
|
||||
required: true,
|
||||
secret: false,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Admin e2e — exercises the migrated /api/admin endpoints through the real
|
||||
* JwtAuthGuard + AdminGuard against a temp SQLite db. The admin service +
|
||||
* helpers are mocked; this focuses on auth (401), the admin gate (403 for a
|
||||
* non-admin), create-201, validation 400 and the dev-only 404.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logInfo: vi.fn() }));
|
||||
vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({})), setAdminPreferences: vi.fn() }));
|
||||
vi.mock('../../src/services/settingsService', () => ({ getAdminUserDefaults: vi.fn(() => ({})), setAdminUserDefaults: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const { adminSvc } = vi.hoisted(() => ({
|
||||
adminSvc: { listUsers: vi.fn(), createUser: vi.fn(), updatePlacesPhotos: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/adminService', () => adminSvc);
|
||||
|
||||
import { AdminModule } from '../../src/nest/admin/admin.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Admin e2e (real auth + admin guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AdminModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
|
||||
seedUser(db as never, { id: 2, role: 'user', email: 'member@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
adminSvc.listUsers.mockReturnValue([{ id: 1 }]);
|
||||
});
|
||||
|
||||
beforeEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session', async () => {
|
||||
expect((await request(server).get('/api/admin/users')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('403 for a non-admin', async () => {
|
||||
const res = await request(server).get('/api/admin/users').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Admin access required' });
|
||||
});
|
||||
|
||||
it('200 list for an admin', async () => {
|
||||
const res = await request(server).get('/api/admin/users').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ users: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('201 on user create', async () => {
|
||||
adminSvc.createUser.mockReturnValue({ user: { id: 3 }, insertedId: 3, auditDetails: {} });
|
||||
const res = await request(server).post('/api/admin/users').set('Cookie', sessionCookie(1)).send({ email: 'new@x.y' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ user: { id: 3 } });
|
||||
});
|
||||
|
||||
it('400 on a non-boolean feature toggle', async () => {
|
||||
const res = await request(server).put('/api/admin/places-photos').set('Cookie', sessionCookie(1)).send({ enabled: 'yes' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'enabled must be a boolean' });
|
||||
});
|
||||
|
||||
it('404 on the dev-only test-notification outside development', async () => {
|
||||
const res = await request(server).post('/api/admin/dev/test-notification').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Airports module e2e — exercises the migrated /api/airports endpoints through
|
||||
* the real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
|
||||
* The airport service is mocked so the test doesn't depend on the bundled dataset.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mockSearch, mockFindByIata } = vi.hoisted(() => ({ mockSearch: vi.fn(), mockFindByIata: vi.fn() }));
|
||||
vi.mock('../../src/services/airportService', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/services/airportService')>();
|
||||
return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata };
|
||||
});
|
||||
|
||||
import { AirportsModule } from '../../src/nest/airports/airports.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const BER = {
|
||||
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
|
||||
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
|
||||
};
|
||||
|
||||
describe('Airports e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AirportsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mockSearch.mockReturnValue([BER]);
|
||||
mockFindByIata.mockImplementation((code: string) => (code === 'BER' ? BER : null));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 { error, code } without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/airports/search').query({ q: 'ber' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('200 with results for a query', async () => {
|
||||
const res = await request(server).get('/api/airports/search').set('Cookie', sessionCookie(1)).query({ q: 'ber' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([BER]);
|
||||
});
|
||||
|
||||
it('200 [] for a missing query without hitting the service', async () => {
|
||||
mockSearch.mockClear();
|
||||
const res = await request(server).get('/api/airports/search').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
expect(mockSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 for a known IATA code', async () => {
|
||||
const res = await request(server).get('/api/airports/BER').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(BER);
|
||||
});
|
||||
|
||||
it('404 { error } for an unknown IATA code', async () => {
|
||||
const res = await request(server).get('/api/airports/ZZZ').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Airport not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Assignments module e2e — exercises both migrated controllers through the real
|
||||
* JwtAuthGuard against a temp SQLite db. assignmentService, journeyService,
|
||||
* the permission check, canAccessTrip and the WebSocket broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn() }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { asg } = vi.hoisted(() => ({
|
||||
asg: {
|
||||
getAssignmentWithPlace: vi.fn(), listDayAssignments: vi.fn(), dayExists: vi.fn(), placeExists: vi.fn(),
|
||||
createAssignment: vi.fn(), assignmentExistsInDay: vi.fn(), deleteAssignment: vi.fn(), reorderAssignments: vi.fn(),
|
||||
getAssignmentForTrip: vi.fn(), moveAssignment: vi.fn(), getParticipants: vi.fn(), updateTime: vi.fn(), setParticipants: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/assignmentService', () => asg);
|
||||
|
||||
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Assignments e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AssignmentsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
asg.listDayAssignments.mockReturnValue([{ id: 1 }]);
|
||||
asg.createAssignment.mockReturnValue({ id: 9 });
|
||||
asg.getParticipants.mockReturnValue([{ user_id: 2 }]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
asg.dayExists.mockReturnValue(true);
|
||||
asg.placeExists.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/days/3/assignments')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list day-assignments', async () => {
|
||||
const res = await request(server).get('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ assignments: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('201 create, 404 place', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 2 });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ assignment: { id: 9 } });
|
||||
asg.placeExists.mockReturnValue(false);
|
||||
const miss = await request(server).post('/api/trips/5/days/3/assignments').set('Cookie', sessionCookie(1)).send({ place_id: 99 });
|
||||
expect(miss.status).toBe(404);
|
||||
expect(miss.body).toEqual({ error: 'Place not found' });
|
||||
});
|
||||
|
||||
it('200 participants (access-only)', async () => {
|
||||
const res = await request(server).get('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ participants: [{ user_id: 2 }] });
|
||||
});
|
||||
|
||||
it('400 set participants with non-array', async () => {
|
||||
const res = await request(server).put('/api/trips/5/assignments/9/participants').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Atlas module e2e — exercises the migrated /api/addons/atlas endpoints through
|
||||
* the real JwtAuthGuard against a temp SQLite db. atlasService is mocked; this
|
||||
* focuses on auth, status codes (mark POSTs stay 200), the cache headers and the
|
||||
* bespoke 400/404 bodies.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
getStats: vi.fn(),
|
||||
getCountryPlaces: vi.fn(),
|
||||
markCountryVisited: vi.fn(),
|
||||
unmarkCountryVisited: vi.fn(),
|
||||
markRegionVisited: vi.fn(),
|
||||
unmarkRegionVisited: vi.fn(),
|
||||
getVisitedRegions: vi.fn(),
|
||||
getRegionGeo: vi.fn(),
|
||||
getCountryGeo: vi.fn(),
|
||||
listBucketList: vi.fn(),
|
||||
createBucketItem: vi.fn(),
|
||||
updateBucketItem: vi.fn(),
|
||||
deleteBucketItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/atlasService', () => mocks);
|
||||
|
||||
import { AtlasModule } from '../../src/nest/atlas/atlas.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Atlas e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AtlasModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mocks.getStats.mockResolvedValue({ countries: 3 });
|
||||
mocks.markCountryVisited.mockReturnValue(undefined);
|
||||
mocks.listBucketList.mockReturnValue([{ id: 1, name: 'Tokyo' }]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/addons/atlas/stats');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 countries/geo returns the admin-0 FeatureCollection', async () => {
|
||||
mocks.getCountryGeo.mockReturnValue({ type: 'FeatureCollection', features: [{ id: 'NO' }] });
|
||||
const res = await request(server).get('/api/addons/atlas/countries/geo').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe('FeatureCollection');
|
||||
expect(res.headers['cache-control']).toContain('max-age=86400');
|
||||
});
|
||||
|
||||
it('200 stats for an authenticated user', async () => {
|
||||
const res = await request(server).get('/api/addons/atlas/stats').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ countries: 3 });
|
||||
});
|
||||
|
||||
it('200 (not 201) on POST country mark, with upper-cased code', async () => {
|
||||
const res = await request(server).post('/api/addons/atlas/country/de/mark').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(mocks.markCountryVisited).toHaveBeenCalledWith(1, 'DE');
|
||||
});
|
||||
|
||||
it('400 on region mark without name/country_code', async () => {
|
||||
const res = await request(server).post('/api/addons/atlas/region/by/mark').set('Cookie', sessionCookie(1)).send({ name: 'Bavaria' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'name and country_code are required' });
|
||||
});
|
||||
|
||||
it('no-store cache header on /regions', async () => {
|
||||
mocks.getVisitedRegions.mockResolvedValue({ regions: {} });
|
||||
const res = await request(server).get('/api/addons/atlas/regions').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['cache-control']).toBe('no-cache, no-store');
|
||||
});
|
||||
|
||||
it('empty FeatureCollection (no cache header) when /regions/geo has no countries', async () => {
|
||||
const res = await request(server).get('/api/addons/atlas/regions/geo').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
expect(res.headers['cache-control']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('201 on bucket-list create', async () => {
|
||||
mocks.createBucketItem.mockReturnValue({ id: 2, name: 'Kyoto' });
|
||||
const res = await request(server).post('/api/addons/atlas/bucket-list').set('Cookie', sessionCookie(1)).send({ name: 'Kyoto' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ item: { id: 2, name: 'Kyoto' } });
|
||||
});
|
||||
|
||||
it('404 on delete of a missing bucket item', async () => {
|
||||
mocks.deleteBucketItem.mockReturnValue(false);
|
||||
const res = await request(server).delete('/api/addons/atlas/bucket-list/9').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Item not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Auth e2e — exercises the migrated /api/auth endpoints through the real
|
||||
* JwtAuthGuard/OptionalJwtGuard AND the real cookie service against a temp
|
||||
* SQLite db. Only the authService (credential/MFA logic) + audit/notifications
|
||||
* are mocked; this proves the httpOnly trek_session cookie is set on login and
|
||||
* cleared on logout, that /me requires a session, and that /app-config is
|
||||
* optional-auth.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://x', sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) }));
|
||||
|
||||
const { authSvc } = vi.hoisted(() => ({
|
||||
authSvc: {
|
||||
getAppConfig: vi.fn(), demoLogin: vi.fn(), validateInviteToken: vi.fn(), registerUser: vi.fn(), loginUser: vi.fn(),
|
||||
requestPasswordReset: vi.fn(), resetPassword: vi.fn(), verifyMfaLogin: vi.fn(), getCurrentUser: vi.fn(),
|
||||
changePassword: vi.fn(), deleteAccount: vi.fn(), updateMapsKey: vi.fn(), updateApiKeys: vi.fn(), updateSettings: vi.fn(),
|
||||
getSettings: vi.fn(), saveAvatar: vi.fn(), deleteAvatar: vi.fn(), listUsers: vi.fn(), validateKeys: vi.fn(),
|
||||
getAppSettings: vi.fn(), updateAppSettings: vi.fn(), getTravelStats: vi.fn(), setupMfa: vi.fn(), enableMfa: vi.fn(),
|
||||
disableMfa: vi.fn(), listMcpTokens: vi.fn(), createMcpToken: vi.fn(), deleteMcpToken: vi.fn(), createWsToken: vi.fn(),
|
||||
createResourceToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/authService', () => authSvc);
|
||||
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, email: 'u@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
authSvc.getAppConfig.mockReturnValue({ version: '3' });
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } });
|
||||
authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /app-config is optional-auth (200 without a cookie)', async () => {
|
||||
authSvc.getAppConfig.mockReturnValue({ version: '3' });
|
||||
const res = await request(server).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ version: '3' });
|
||||
});
|
||||
|
||||
it('GET /me requires a session (401 without a cookie)', async () => {
|
||||
expect((await request(server).get('/api/auth/me')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /me returns the user with a valid session', async () => {
|
||||
authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' });
|
||||
const res = await request(server).get('/api/auth/me').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ user: { id: 1, email: 'u@example.test' } });
|
||||
});
|
||||
|
||||
it('POST /login sets the httpOnly trek_session cookie', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 } });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ token: 'jwt.token.value', user: { id: 1 } });
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login with remember_me sets a persistent cookie (Max-Age present)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: true });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw', remember_me: true });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).toMatch(/Max-Age=\d+/i);
|
||||
// 30d default — well above the 24h (86400s) non-remember window.
|
||||
const maxAge = Number(/Max-Age=(\d+)/i.exec(cookie)?.[1]);
|
||||
expect(maxAge).toBeGreaterThan(86_400);
|
||||
}, 10000);
|
||||
|
||||
it('POST /login without remember_me sets a session cookie (no Max-Age)', async () => {
|
||||
authSvc.loginUser.mockReturnValue({ token: 'jwt.token.value', user: { id: 1 }, remember: false });
|
||||
const res = await request(server).post('/api/auth/login').send({ email: 'u@example.test', password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
const cookie = setCookie.find((c) => c.startsWith('trek_session='))!;
|
||||
expect(cookie).not.toMatch(/Max-Age/i);
|
||||
expect(cookie).not.toMatch(/Expires/i);
|
||||
}, 10000);
|
||||
|
||||
it('POST /logout clears the session cookie', async () => {
|
||||
const res = await request(server).post('/api/auth/logout');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session='))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Backup e2e — exercises the migrated /api/backup endpoints through the real
|
||||
* JwtAuthGuard + AdminGuard against a temp SQLite db. The backup service +
|
||||
* audit log are mocked; this focuses on auth (401), the admin gate (403 for a
|
||||
* non-admin), the rate-limit 429, filename guards and status codes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
|
||||
const { backupSvc } = vi.hoisted(() => ({
|
||||
backupSvc: {
|
||||
listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), getAutoSettings: vi.fn(),
|
||||
updateAutoSettings: vi.fn(), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(),
|
||||
backupFileExists: vi.fn(), checkRateLimit: vi.fn(), getUploadTmpDir: () => '/tmp', BACKUP_RATE_WINDOW: 3600000,
|
||||
MAX_BACKUP_UPLOAD_SIZE: 1024,
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/backupService', () => backupSvc);
|
||||
|
||||
import { BackupModule } from '../../src/nest/backup/backup.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Backup e2e (real auth + admin guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
|
||||
seedUser(db as never, { id: 2, role: 'user', email: 'member@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
backupSvc.listBackups.mockReturnValue([{ filename: 'a.zip', size: 1 }]);
|
||||
backupSvc.createBackup.mockResolvedValue({ filename: 'b.zip', size: 10 });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
backupSvc.isValidBackupFilename.mockReturnValue(true);
|
||||
backupSvc.backupFileExists.mockReturnValue(true);
|
||||
backupSvc.checkRateLimit.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/backup/list')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('403 for a non-admin', async () => {
|
||||
const res = await request(server).get('/api/backup/list').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Admin access required' });
|
||||
});
|
||||
|
||||
it('200 list for an admin', async () => {
|
||||
const res = await request(server).get('/api/backup/list').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ backups: [{ filename: 'a.zip', size: 1 }] });
|
||||
});
|
||||
|
||||
it('429 when create is rate-limited', async () => {
|
||||
backupSvc.checkRateLimit.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/backup/create').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.body).toEqual({ error: 'Too many backup requests. Please try again later.' });
|
||||
});
|
||||
|
||||
it('400 on an invalid download filename', async () => {
|
||||
backupSvc.isValidBackupFilename.mockReturnValue(false);
|
||||
const res = await request(server).get('/api/backup/download/bad').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Invalid filename' });
|
||||
});
|
||||
|
||||
it('404 deleting a missing backup', async () => {
|
||||
backupSvc.backupFileExists.mockReturnValue(false);
|
||||
const res = await request(server).delete('/api/backup/x.zip').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Backup not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Budget module e2e — exercises the migrated /api/trips/:tripId/budget endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. budgetService, the
|
||||
* permission check and the WebSocket broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/budgetService', () => svc);
|
||||
|
||||
import { BudgetModule } from '../../src/nest/budget/budget.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Budget e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]);
|
||||
svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/trips/5/budget');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ items: [{ id: 1, name: 'Hotel' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
svc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/budget').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 on create with permission', async () => {
|
||||
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ item: { id: 9, name: 'Hotel' } });
|
||||
});
|
||||
|
||||
it('403 on create without permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/budget').set('Cookie', sessionCookie(1)).send({ name: 'Hotel' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
|
||||
it('400 on member update with a non-array user_ids', async () => {
|
||||
const res = await request(server).put('/api/trips/5/budget/9/members').set('Cookie', sessionCookie(1)).send({ user_ids: 'no' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'user_ids must be an array' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Categories module e2e — exercises the migrated /api/categories endpoints
|
||||
* through the real JwtAuthGuard + AdminGuard against a temp SQLite db seeded
|
||||
* with an admin and a normal user. categoryService is mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
listCategories: vi.fn(),
|
||||
createCategory: vi.fn(),
|
||||
getCategoryById: vi.fn(),
|
||||
updateCategory: vi.fn(),
|
||||
deleteCategory: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/categoryService', () => mocks);
|
||||
|
||||
import { CategoriesModule } from '../../src/nest/categories/categories.module';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const cat = { id: 1, name: 'Food', color: '#fff', icon: '🍔' };
|
||||
|
||||
describe('Categories e2e (real JwtAuthGuard + AdminGuard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CategoriesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
|
||||
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mocks.listCategories.mockReturnValue([cat]);
|
||||
mocks.createCategory.mockReturnValue(cat);
|
||||
mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined));
|
||||
mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/categories');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for any authenticated user (non-admin allowed)', async () => {
|
||||
const res = await request(server).get('/api/categories').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ categories: [cat] });
|
||||
});
|
||||
|
||||
it('403 when a non-admin tries to create', async () => {
|
||||
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(2)).send({ name: 'X' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Admin access required' });
|
||||
expect(mocks.createCategory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('201 when an admin creates a category', async () => {
|
||||
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({ name: 'Food', color: '#fff', icon: '🍔' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ category: cat });
|
||||
expect(mocks.createCategory).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔');
|
||||
});
|
||||
|
||||
it('400 when an admin creates without a name', async () => {
|
||||
const res = await request(server).post('/api/categories').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Category name is required' });
|
||||
});
|
||||
|
||||
it('200 when an admin updates an existing category', async () => {
|
||||
const res = await request(server).put('/api/categories/1').set('Cookie', sessionCookie(1)).send({ name: 'Drinks' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ category: { ...cat, name: 'Drinks' } });
|
||||
});
|
||||
|
||||
it('404 when an admin updates a missing category', async () => {
|
||||
const res = await request(server).put('/api/categories/9').set('Cookie', sessionCookie(1)).send({ name: 'X' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Category not found' });
|
||||
});
|
||||
|
||||
it('200 when an admin deletes an existing category', async () => {
|
||||
const res = await request(server).delete('/api/categories/1').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(mocks.deleteCategory).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Collab module e2e — exercises the migrated /api/trips/:tripId/collab endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The collab service,
|
||||
* permission check, WebSocket broadcast and the chat/note notification are
|
||||
* mocked; this focuses on auth, trip-access 404, permission 403, the create-201
|
||||
* status codes and the vote/react 200 overrides.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
// The note/message notifications read the trip title fire-and-forget; the table
|
||||
// must exist so that query doesn't throw after the test has torn down.
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
tmp.prepare("INSERT INTO trips (id, title) VALUES (5, 'Trip')").run();
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(), listNotes: vi.fn(), createNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
|
||||
addNoteFile: vi.fn(), getFormattedNoteById: vi.fn(), deleteNoteFile: vi.fn(),
|
||||
listPolls: vi.fn(), createPoll: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), deletePoll: vi.fn(),
|
||||
listMessages: vi.fn(), createMessage: vi.fn(), deleteMessage: vi.fn(), addOrRemoveReaction: vi.fn(), fetchLinkPreview: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/collabService', () => svc);
|
||||
|
||||
import { CollabModule } from '../../src/nest/collab/collab.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Collab e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
svc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]);
|
||||
svc.createNote.mockReturnValue({ id: 9, title: 'N' });
|
||||
svc.createPoll.mockReturnValue({ id: 7 });
|
||||
svc.votePoll.mockReturnValue({ poll: { id: 7 } });
|
||||
svc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } });
|
||||
svc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] });
|
||||
svc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/collab/notes')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list notes for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ notes: [{ id: 1, title: 'N' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
svc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 on note create with permission', async () => {
|
||||
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ note: { id: 9, title: 'N' } });
|
||||
});
|
||||
|
||||
it('403 on note create without permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/collab/notes').set('Cookie', sessionCookie(1)).send({ title: 'N' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
|
||||
it('200 on poll vote (not 201)', async () => {
|
||||
const res = await request(server).post('/api/trips/5/collab/polls/7/vote').set('Cookie', sessionCookie(1)).send({ option_index: 0 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('201 on message create', async () => {
|
||||
const res = await request(server).post('/api/trips/5/collab/messages').set('Cookie', sessionCookie(1)).send({ text: 'hi' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ message: { id: 3, text: 'hi' } });
|
||||
});
|
||||
|
||||
it('200 on react (not 201)', async () => {
|
||||
const res = await request(server).post('/api/trips/5/collab/messages/3/react').set('Cookie', sessionCookie(1)).send({ emoji: '👍' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
|
||||
});
|
||||
|
||||
it('400 on link-preview without a url', async () => {
|
||||
const res = await request(server).get('/api/trips/5/collab/link-preview').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'URL is required' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Public config e2e — verifies /api/config is reachable WITHOUT authentication
|
||||
* (it has no guard) and returns the server default language. No db needed.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ConfigModule } from '../../src/nest/config/config.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { DEFAULT_LANGUAGE } from '../../src/config';
|
||||
|
||||
describe('Public config e2e (no auth guard)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [ConfigModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('200 with the default language and no cookie required', async () => {
|
||||
const res = await request(server).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ defaultLanguage: DEFAULT_LANGUAGE });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Days + day-notes module e2e — exercises both migrated mounts through the real
|
||||
* JwtAuthGuard against a temp SQLite db. The day/day-note services, the
|
||||
* permission check, canAccessTrip and the WebSocket broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { day, note } = vi.hoisted(() => ({
|
||||
day: { listDays: vi.fn(), createDay: vi.fn(), getDay: vi.fn(), updateDay: vi.fn(), deleteDay: vi.fn() },
|
||||
note: {
|
||||
verifyTripAccess: vi.fn(), listNotes: vi.fn(), dayExists: vi.fn(), createNote: vi.fn(),
|
||||
getNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/dayService', () => day);
|
||||
vi.mock('../../src/services/dayNoteService', () => note);
|
||||
|
||||
import { DaysModule } from '../../src/nest/days/days.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Days + day-notes e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DaysModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
day.listDays.mockReturnValue({ days: [{ id: 1 }] });
|
||||
day.createDay.mockReturnValue({ id: 9 });
|
||||
note.listNotes.mockReturnValue([{ id: 1 }]);
|
||||
note.dayExists.mockReturnValue(true);
|
||||
note.createNote.mockReturnValue({ id: 7 });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
note.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/days')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list days (the { days } envelope)', async () => {
|
||||
const res = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ days: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('201 create day, 404 trip when not accessible', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/days').set('Cookie', sessionCookie(1)).send({ date: '2026-07-01' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ day: { id: 9 } });
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const miss = await request(server).get('/api/trips/5/days').set('Cookie', sessionCookie(1));
|
||||
expect(miss.status).toBe(404);
|
||||
expect(miss.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 create note, 400 on over-long text (before access)', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'Lunch' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ note: { id: 7 } });
|
||||
const long = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: 'x'.repeat(501) });
|
||||
expect(long.status).toBe(400);
|
||||
expect(long.body).toEqual({ error: 'text must be 500 characters or less' });
|
||||
});
|
||||
|
||||
it('400 note without text', async () => {
|
||||
const res = await request(server).post('/api/trips/5/days/3/notes').set('Cookie', sessionCookie(1)).send({ text: ' ' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Text required' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Files + photos e2e — exercises the migrated /api/trips/:tripId/files and
|
||||
* /api/photos endpoints through the real JwtAuthGuard against a temp SQLite db.
|
||||
* The file/photo services, permission check and broadcast are mocked; this
|
||||
* focuses on auth (incl. the unguarded download's own token auth), trip-access
|
||||
* 404, permission 403, the photo id/access guards and status codes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { fileSvc } = vi.hoisted(() => ({
|
||||
fileSvc: {
|
||||
MAX_FILE_SIZE: 50 * 1024 * 1024, BLOCKED_EXTENSIONS: ['.exe', '.svg'], filesDir: '/tmp/files', getAllowedExtensions: () => '*',
|
||||
verifyTripAccess: vi.fn(), resolveFilePath: vi.fn(), authenticateDownload: vi.fn(),
|
||||
listFiles: vi.fn(), getFileById: vi.fn(), getDeletedFile: vi.fn(), createFile: vi.fn(), updateFile: vi.fn(),
|
||||
toggleStarred: vi.fn(), softDeleteFile: vi.fn(), restoreFile: vi.fn(), permanentDeleteFile: vi.fn(),
|
||||
emptyTrash: vi.fn(), createFileLink: vi.fn(), deleteFileLink: vi.fn(), getFileLinks: vi.fn(), formatFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/fileService', () => fileSvc);
|
||||
|
||||
const { photoSvc, helperSvc } = vi.hoisted(() => ({
|
||||
photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() },
|
||||
helperSvc: { canAccessTrekPhoto: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/memories/photoResolverService', () => photoSvc);
|
||||
vi.mock('../../src/services/memories/helpersService', () => helperSvc);
|
||||
|
||||
import { FilesModule } from '../../src/nest/files/files.module';
|
||||
import { PhotosModule } from '../../src/nest/photos/photos.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Files + photos e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [FilesModule, PhotosModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
fileSvc.listFiles.mockReturnValue([{ id: 1, original_name: 'a.pdf' }]);
|
||||
fileSvc.getFileById.mockReturnValue({ id: 9, starred: 0 });
|
||||
fileSvc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fileSvc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
helperSvc.canAccessTrekPhoto.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 listing files without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/files')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/files').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ files: [{ id: 1, original_name: 'a.pdf' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
fileSvc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/files').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('200 toggling a star with permission', async () => {
|
||||
const res = await request(server).patch('/api/trips/5/files/9/star').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ file: { id: 9, starred: 1 } });
|
||||
});
|
||||
|
||||
it('403 deleting without file_delete permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).delete('/api/trips/5/files/9').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission to delete files' });
|
||||
});
|
||||
|
||||
it('download is unguarded but enforces its own token auth (401 without one)', async () => {
|
||||
fileSvc.authenticateDownload.mockReturnValue({ error: 'Authentication required', status: 401 });
|
||||
const res = await request(server).get('/api/trips/5/files/9/download');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Authentication required' });
|
||||
});
|
||||
|
||||
it('400 on a photo with a non-finite id', async () => {
|
||||
const res = await request(server).get('/api/photos/abc/thumbnail').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Invalid photo ID' });
|
||||
});
|
||||
|
||||
it('403 on a photo the user cannot access', async () => {
|
||||
helperSvc.canAccessTrekPhoto.mockReturnValue(false);
|
||||
const res = await request(server).get('/api/photos/5/original').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Forbidden' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../../src/config';
|
||||
|
||||
/**
|
||||
* Shared e2e harness for migrated Nest modules.
|
||||
*
|
||||
* Gives each module e2e test a throwaway in-memory SQLite db (the same shape the
|
||||
* shared connection exposes), a seed helper for demo data, and a session-cookie
|
||||
* signer that produces tokens the REAL JwtAuthGuard accepts — so e2e tests cover
|
||||
* the actual auth path end-to-end, not a stubbed guard.
|
||||
*
|
||||
* Wire it in a test with `vi.mock('../../src/db/database', () => ({ db, ... }))`
|
||||
* using the db returned here, then build the Nest app under test.
|
||||
*/
|
||||
|
||||
export interface SeededUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: 'user' | 'admin';
|
||||
password_version: number;
|
||||
}
|
||||
|
||||
/** Fresh in-memory db with the minimal `users` table the auth guard reads. */
|
||||
export function createTempDb(): Database.Database {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
password_version INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Insert a demo user and return its row. */
|
||||
export function seedUser(db: Database.Database, overrides: Partial<SeededUser> = {}): SeededUser {
|
||||
const user: SeededUser = {
|
||||
id: overrides.id ?? 1,
|
||||
username: overrides.username ?? 'e2e-user',
|
||||
email: overrides.email ?? 'e2e@example.test',
|
||||
role: overrides.role ?? 'user',
|
||||
password_version: overrides.password_version ?? 0,
|
||||
};
|
||||
db.prepare(
|
||||
'INSERT INTO users (id, username, email, role, password_version) VALUES (?, ?, ?, ?, ?)',
|
||||
).run(user.id, user.username, user.email, user.role, user.password_version);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** Sign a `trek_session` token the real guard will accept (matching JWT_SECRET + pv). */
|
||||
export function signSession(userId: number, passwordVersion = 0): string {
|
||||
return jwt.sign({ id: userId, pv: passwordVersion }, JWT_SECRET, { algorithm: 'HS256' });
|
||||
}
|
||||
|
||||
/** Convenience: the Cookie header value for a signed session. */
|
||||
export function sessionCookie(userId: number, passwordVersion = 0): string {
|
||||
return `trek_session=${signSession(userId, passwordVersion)}`;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Journey e2e — exercises the migrated /api/journeys and /api/public/journey
|
||||
* endpoints through the real JwtAuthGuard against a temp SQLite db. The journey
|
||||
* services + addon gate are mocked; this focuses on the addon-gate-before-auth
|
||||
* ordering (404 wins over 401), auth, the service-owned 403/404 mapping, status
|
||||
* codes and the unguarded public route.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
vi.mock('../../src/services/fileService', () => ({ getAllowedExtensions: () => '*' }));
|
||||
vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() }));
|
||||
vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() }));
|
||||
|
||||
const { jsvc } = vi.hoisted(() => ({
|
||||
jsvc: { listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/journeyService', () => jsvc);
|
||||
|
||||
const { sharesvc } = vi.hoisted(() => ({ sharesvc: { getPublicJourney: vi.fn() } }));
|
||||
vi.mock('../../src/services/journeyShareService', () => sharesvc);
|
||||
|
||||
import { JourneyModule } from '../../src/nest/journey/journey.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Journey e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]);
|
||||
jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' });
|
||||
sharesvc.getPublicJourney.mockReturnValue({ id: 9 });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
isAddonEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('404 (addon gate wins over auth) when the Journey addon is disabled', async () => {
|
||||
isAddonEnabled.mockReturnValue(false);
|
||||
const res = await request(server).get('/api/journeys');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Journey addon is not enabled' });
|
||||
});
|
||||
|
||||
it('401 with the addon enabled but no session cookie', async () => {
|
||||
expect((await request(server).get('/api/journeys')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list with a session', async () => {
|
||||
const res = await request(server).get('/api/journeys').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ journeys: [{ id: 1, title: 'J' }] });
|
||||
});
|
||||
|
||||
it('201 create, 400 without a title', async () => {
|
||||
const ok = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({ title: 'J' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ id: 9, title: 'J' });
|
||||
const bad = await request(server).post('/api/journeys').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Title is required' });
|
||||
});
|
||||
|
||||
it('404 for an inaccessible journey', async () => {
|
||||
jsvc.getJourneyFull.mockReturnValue(null);
|
||||
const res = await request(server).get('/api/journeys/9').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Journey not found' });
|
||||
});
|
||||
|
||||
it('public journey read is unguarded (200 with a valid token, no cookie)', async () => {
|
||||
const res = await request(server).get('/api/public/journey/tok');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ id: 9 });
|
||||
});
|
||||
|
||||
it('public journey 404 for an unknown token', async () => {
|
||||
sharesvc.getPublicJourney.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/public/journey/bad');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Maps module e2e — exercises the migrated /api/maps endpoints through the real
|
||||
* JwtAuthGuard against a temp SQLite db. mapsService is mocked (no outbound HTTP),
|
||||
* and the temp db carries an empty app_settings table so the kill-switch reads
|
||||
* resolve to "enabled".
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
searchPlaces: vi.fn(),
|
||||
autocompletePlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlaceDetailsExpanded: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/mapsService', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/services/mapsService')>();
|
||||
return { ...actual, ...mocks };
|
||||
});
|
||||
|
||||
import { MapsModule } from '../../src/nest/maps/maps.module';
|
||||
import { DatabaseModule } from '../../src/nest/database/database.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Maps e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' });
|
||||
mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).post('/api/maps/search').send({ query: 'berlin' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('400 when authenticated but query is missing', async () => {
|
||||
const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Search query is required' });
|
||||
});
|
||||
|
||||
it('200 with results for a search (POST stays 200, not 201)', async () => {
|
||||
const res = await request(server).post('/api/maps/search').set('Cookie', sessionCookie(1)).send({ query: 'berlin' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ places: [{ name: 'Berlin' }], source: 'osm' });
|
||||
});
|
||||
|
||||
it('200 on reverse geocode', async () => {
|
||||
const res = await request(server).get('/api/maps/reverse').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ name: 'Spot', address: 'Street 1' });
|
||||
});
|
||||
|
||||
it('400 on reverse geocode without coordinates', async () => {
|
||||
const res = await request(server).get('/api/maps/reverse').set('Cookie', sessionCookie(1)).query({ lat: '52.5' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'lat and lng required' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Memories (photo-providers) module e2e — exercises the migrated
|
||||
* /api/integrations/memories endpoints (unified + immich + synologyphotos)
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The provider services
|
||||
* and canAccessUserPhoto are mocked; fail/success stay real so the envelope
|
||||
* shapes are produced by the actual helper code.
|
||||
*
|
||||
* Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure
|
||||
* behaviour of /test + /status, and at least one error envelope per provider
|
||||
* router — all asserted byte-identical to the legacy Express routers.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// Provider services — fully mocked. fail/success/canAccessUserPhoto from the
|
||||
// helper module are kept real except canAccessUserPhoto which we override.
|
||||
const { unified, immich, synology } = vi.hoisted(() => ({
|
||||
unified: {
|
||||
listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(),
|
||||
removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(),
|
||||
},
|
||||
immich: {
|
||||
getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(),
|
||||
testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(),
|
||||
streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(),
|
||||
getAssetInfo: vi.fn(), isValidAssetId: vi.fn(),
|
||||
},
|
||||
synology: {
|
||||
getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(),
|
||||
testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(),
|
||||
syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(),
|
||||
streamSynologyAsset: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/memories/unifiedService', () => unified);
|
||||
vi.mock('../../src/services/memories/immichService', () => immich);
|
||||
vi.mock('../../src/services/memories/synologyService', () => synology);
|
||||
|
||||
const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() }));
|
||||
vi.mock('../../src/services/memories/helpersService', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/services/memories/helpersService')>(
|
||||
'../../src/services/memories/helpersService',
|
||||
);
|
||||
return { ...actual, canAccessUserPhoto };
|
||||
});
|
||||
|
||||
import { MemoriesModule } from '../../src/nest/memories/memories.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const BASE = '/api/integrations/memories';
|
||||
const UNIFIED = `${BASE}/unified`;
|
||||
const IMMICH = `${BASE}/immich`;
|
||||
const SYNO = `${BASE}/synologyphotos`;
|
||||
|
||||
describe('Memories e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canAccessUserPhoto.mockReturnValue(true);
|
||||
immich.isValidAssetId.mockReturnValue(true);
|
||||
});
|
||||
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||
describe('auth', () => {
|
||||
it('401 without a cookie (unified photos)', async () => {
|
||||
expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401);
|
||||
});
|
||||
it('401 without a cookie (immich status)', async () => {
|
||||
expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401);
|
||||
});
|
||||
it('401 without a cookie (synology albums)', async () => {
|
||||
expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Unified ──────────────────────────────────────────────────────────────────
|
||||
describe('unified', () => {
|
||||
it('200 list photos -> { photos }', async () => {
|
||||
unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] });
|
||||
const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] });
|
||||
});
|
||||
|
||||
it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => {
|
||||
unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } });
|
||||
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1))
|
||||
.send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, added: 2 });
|
||||
// x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`.
|
||||
expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined);
|
||||
});
|
||||
|
||||
it('400 add photos with empty selections -> error envelope', async () => {
|
||||
unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } });
|
||||
const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'No photos selected' });
|
||||
});
|
||||
|
||||
it('200 PUT sharing -> { success: true }', async () => {
|
||||
unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true });
|
||||
const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('404 DELETE photo on inaccessible trip -> error envelope', async () => {
|
||||
unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } });
|
||||
const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found or access denied' });
|
||||
});
|
||||
|
||||
it('200 list album-links -> { links }', async () => {
|
||||
unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] });
|
||||
const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ links: [{ id: 'l1' }] });
|
||||
});
|
||||
|
||||
it('200 create album-link / 409 duplicate envelope', async () => {
|
||||
unified.createTripAlbumLink.mockReturnValue({ success: true, data: true });
|
||||
const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
|
||||
unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } });
|
||||
const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' });
|
||||
expect(dup.status).toBe(409);
|
||||
expect(dup.body).toEqual({ error: 'Album already linked' });
|
||||
});
|
||||
|
||||
it('200 DELETE album-link -> { success: true }', async () => {
|
||||
unified.removeAlbumLink.mockReturnValue({ success: true, data: true });
|
||||
const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Immich ───────────────────────────────────────────────────────────────────
|
||||
describe('immich', () => {
|
||||
it('200 settings', async () => {
|
||||
immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false });
|
||||
const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false });
|
||||
});
|
||||
|
||||
it('200 PUT settings success / 400 invalid url', async () => {
|
||||
immich.saveImmichSettings.mockResolvedValue({ success: true });
|
||||
const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true });
|
||||
expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true);
|
||||
|
||||
immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' });
|
||||
const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
|
||||
immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' });
|
||||
const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Not configured' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => {
|
||||
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'URL and API key required' });
|
||||
expect(immich.testConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 /test with creds delegates to service', async () => {
|
||||
immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } });
|
||||
const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: true, user: { name: 'T' } });
|
||||
});
|
||||
|
||||
it('200 browse / 400 not configured', async () => {
|
||||
immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] });
|
||||
const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ buckets: [{ count: 3 }] });
|
||||
|
||||
immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 });
|
||||
const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1));
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Immich not configured' });
|
||||
});
|
||||
|
||||
it('200 search (POST stays 200) / 502 envelope', async () => {
|
||||
immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true });
|
||||
const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true });
|
||||
expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50);
|
||||
|
||||
immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 });
|
||||
const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({});
|
||||
expect(bad.status).toBe(502);
|
||||
expect(bad.body).toEqual({ error: 'Could not reach Immich' });
|
||||
});
|
||||
|
||||
it('200 asset info / 400 invalid id / 403 no access', async () => {
|
||||
immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } });
|
||||
const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' });
|
||||
|
||||
immich.isValidAssetId.mockReturnValue(false);
|
||||
const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body).toEqual({ error: 'Invalid asset ID' });
|
||||
|
||||
immich.isValidAssetId.mockReturnValue(true);
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: 'Forbidden' });
|
||||
});
|
||||
|
||||
it('streams thumbnail bytes via the service helper', async () => {
|
||||
immich.streamImmichAsset.mockImplementation(async (res: any) => {
|
||||
res.status(200);
|
||||
res.set('Content-Type', 'image/webp');
|
||||
res.end(Buffer.from('thumb-bytes'));
|
||||
});
|
||||
const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/webp');
|
||||
expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1);
|
||||
});
|
||||
|
||||
it('200 albums / 200 album photos', async () => {
|
||||
immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] });
|
||||
const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1));
|
||||
expect(albums.status).toBe(200);
|
||||
expect(albums.body).toEqual({ albums: [{ id: 'al' }] });
|
||||
|
||||
immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] });
|
||||
const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1));
|
||||
expect(photos.status).toBe(200);
|
||||
expect(photos.body).toEqual({ assets: [{ id: 'p1' }] });
|
||||
});
|
||||
|
||||
it('200 album sync (POST stays 200) / 404 envelope', async () => {
|
||||
immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 });
|
||||
const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ success: true, added: 3, total: 10 });
|
||||
|
||||
immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 });
|
||||
const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(bad.status).toBe(404);
|
||||
expect(bad.body).toEqual({ error: 'Album link not found' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Synology ───────────────────────────────────────────────────────────────
|
||||
describe('synologyphotos', () => {
|
||||
it('200 settings', async () => {
|
||||
synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } });
|
||||
const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true });
|
||||
});
|
||||
|
||||
it('400 PUT settings without url/username -> envelope', async () => {
|
||||
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'URL and username are required' });
|
||||
expect(synology.updateSynologySettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 PUT settings delegates when valid', async () => {
|
||||
synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' });
|
||||
const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual('settings updated');
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /status with { connected: false } on failure', async () => {
|
||||
synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } });
|
||||
const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Synology not configured' });
|
||||
});
|
||||
|
||||
it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => {
|
||||
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' });
|
||||
expect(synology.testSynologyConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 /test delegates when all fields present', async () => {
|
||||
synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } });
|
||||
const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ connected: true, user: { name: 'admin' } });
|
||||
});
|
||||
|
||||
it('200 albums / 200 album photos with passphrase', async () => {
|
||||
synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } });
|
||||
const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1));
|
||||
expect(albums.status).toBe(200);
|
||||
expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] });
|
||||
|
||||
synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } });
|
||||
const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1));
|
||||
expect(photos.status).toBe(200);
|
||||
expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false });
|
||||
expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret');
|
||||
});
|
||||
|
||||
it('200 search (POST stays 200) with offset/limit coercion', async () => {
|
||||
synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } });
|
||||
const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ assets: [], total: 0, hasMore: false });
|
||||
// page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40
|
||||
expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20);
|
||||
});
|
||||
|
||||
it('200 album sync (POST stays 200)', async () => {
|
||||
synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } });
|
||||
const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ added: 2, total: 5 });
|
||||
});
|
||||
|
||||
it('200 asset info / 403 distinct synology string on no access', async () => {
|
||||
synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } });
|
||||
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ id: '40808_1', takenAt: null });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
|
||||
});
|
||||
|
||||
it('400 invalid asset kind / 403 no access / stream on valid kind', async () => {
|
||||
const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1));
|
||||
expect(invalid.status).toBe(400);
|
||||
expect(invalid.body).toEqual({ error: 'Invalid asset kind' });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(false);
|
||||
const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1));
|
||||
expect(forbidden.status).toBe(403);
|
||||
expect(forbidden.body).toEqual({ error: "You don't have access to this photo" });
|
||||
|
||||
canAccessUserPhoto.mockReturnValue(true);
|
||||
synology.streamSynologyAsset.mockImplementation(async (res: any) => {
|
||||
res.status(200);
|
||||
res.set('Content-Type', 'image/jpeg');
|
||||
res.end(Buffer.from('syno-bytes'));
|
||||
});
|
||||
const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1));
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.headers['content-type']).toContain('image/jpeg');
|
||||
expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Notifications module e2e — exercises the migrated /api/notifications endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The notification
|
||||
* services are mocked; this focuses on auth, the inline admin gate on
|
||||
* /test-smtp, routing (the /in-app/all ordering trap) and status/body shapes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { prefs, inapp, channels } = vi.hoisted(() => ({
|
||||
prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() },
|
||||
inapp: {
|
||||
getNotifications: vi.fn(), getUnreadCount: vi.fn(), markRead: vi.fn(), markUnread: vi.fn(),
|
||||
markAllRead: vi.fn(), deleteNotification: vi.fn(), deleteAll: vi.fn(), respondToBoolean: vi.fn(),
|
||||
},
|
||||
channels: {
|
||||
testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(),
|
||||
getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(),
|
||||
getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/notificationPreferencesService', () => prefs);
|
||||
vi.mock('../../src/services/inAppNotifications', () => inapp);
|
||||
vi.mock('../../src/services/notifications', () => channels);
|
||||
|
||||
import { NotificationsModule } from '../../src/nest/notifications/notifications.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Notifications e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1, role: 'admin', email: 'admin@example.test' });
|
||||
seedUser(db as never, { id: 2, role: 'user', email: 'user@example.test' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} });
|
||||
inapp.getUnreadCount.mockReturnValue(2);
|
||||
inapp.deleteAll.mockReturnValue(4);
|
||||
channels.testSmtp.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/notifications/preferences');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 preferences for an authenticated user', async () => {
|
||||
const res = await request(server).get('/api/notifications/preferences').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ preferences: {} });
|
||||
});
|
||||
|
||||
it('403 { error: Admin only } when a non-admin hits test-smtp', async () => {
|
||||
const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(2)).send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'Admin only' });
|
||||
expect(channels.testSmtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('200 test-smtp for an admin (stays 200, not 201)', async () => {
|
||||
const res = await request(server).post('/api/notifications/test-smtp').set('Cookie', sessionCookie(1)).send({ email: 'x@y.z' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('200 unread-count', async () => {
|
||||
const res = await request(server).get('/api/notifications/in-app/unread-count').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ count: 2 });
|
||||
});
|
||||
|
||||
it('DELETE /in-app/all hits deleteAll, not deleteNotification', async () => {
|
||||
const res = await request(server).delete('/api/notifications/in-app/all').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, count: 4 });
|
||||
expect(inapp.deleteAll).toHaveBeenCalledWith(2);
|
||||
expect(inapp.deleteNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 on a non-numeric in-app id', async () => {
|
||||
const res = await request(server).put('/api/notifications/in-app/abc/read').set('Cookie', sessionCookie(2));
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Invalid id' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* OAuth e2e — exercises the migrated /oauth/* and /api/oauth/* endpoints through
|
||||
* the real JwtAuthGuard / CookieAuthGuard / OptionalJwtGuard against a temp
|
||||
* SQLite db. The OAuth service + addon gate are mocked; this focuses on the
|
||||
* public token/userinfo guards, the MCP 404/403 gates, and the cookie-only auth
|
||||
* on the management endpoints (a Bearer must NOT satisfy them).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie, signSession } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' }));
|
||||
|
||||
const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../src/services/adminService', () => ({ isAddonEnabled }));
|
||||
|
||||
const { oauthSvc } = vi.hoisted(() => ({
|
||||
oauthSvc: {
|
||||
validateAuthorizeRequest: vi.fn(), createAuthCode: vi.fn(), consumeAuthCode: vi.fn(), saveConsent: vi.fn(),
|
||||
issueTokens: vi.fn(), issueClientCredentialsToken: vi.fn(), refreshTokens: vi.fn(), revokeToken: vi.fn(),
|
||||
verifyPKCE: vi.fn(), authenticateClient: vi.fn(), listOAuthClients: vi.fn(), createOAuthClient: vi.fn(),
|
||||
deleteOAuthClient: vi.fn(), rotateOAuthClientSecret: vi.fn(), listOAuthSessions: vi.fn(), revokeSession: vi.fn(),
|
||||
getUserByAccessToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/oauthService', () => oauthSvc);
|
||||
|
||||
import { OauthModule } from '../../src/nest/oauth/oauth.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('OAuth e2e (real guards + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [OauthModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]);
|
||||
});
|
||||
|
||||
beforeEach(() => { isAddonEnabled.mockReturnValue(true); });
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('POST /oauth/token is public — 401 invalid_client without client_id', async () => {
|
||||
const res = await request(server).post('/oauth/token').send({});
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'invalid_client', error_description: 'client_id is required' });
|
||||
expect(res.headers['cache-control']).toBe('no-store');
|
||||
});
|
||||
|
||||
it('POST /oauth/token 404 (empty) when MCP is disabled', async () => {
|
||||
isAddonEnabled.mockReturnValue(false);
|
||||
const res = await request(server).post('/oauth/token').send({ client_id: 'c' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.text).toBe('');
|
||||
});
|
||||
|
||||
it('GET /oauth/userinfo 401 with a WWW-Authenticate challenge', async () => {
|
||||
const res = await request(server).get('/oauth/userinfo');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.headers['www-authenticate']).toContain('Bearer');
|
||||
});
|
||||
|
||||
it('GET /api/oauth/clients 401 without a session', async () => {
|
||||
expect((await request(server).get('/api/oauth/clients')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('GET /api/oauth/clients works with a Bearer (authenticate) session', async () => {
|
||||
const res = await request(server).get('/api/oauth/clients').set('Authorization', `Bearer ${signSession(1)}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ clients: [{ id: 'c1' }] });
|
||||
});
|
||||
|
||||
it('POST /api/oauth/clients requires a COOKIE session (a Bearer is rejected)', async () => {
|
||||
const bearer = await request(server).post('/api/oauth/clients').set('Authorization', `Bearer ${signSession(1)}`).send({ name: 'CLI', allowed_scopes: ['a'] });
|
||||
expect(bearer.status).toBe(401);
|
||||
expect(bearer.body).toEqual({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
|
||||
|
||||
oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' });
|
||||
const cookie = await request(server).post('/api/oauth/clients').set('Cookie', sessionCookie(1)).send({ name: 'CLI', allowed_scopes: ['a'] });
|
||||
expect(cookie.status).toBe(201);
|
||||
expect(cookie.body).toEqual({ client_id: 'c1', client_secret: 's' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* OIDC e2e — exercises the migrated /api/auth/oidc flow with the real cookie
|
||||
* service. The OIDC service + auth toggles are mocked; this proves the flow is
|
||||
* unauthenticated, the sso-disabled 403, the login redirect, and that /exchange
|
||||
* sets the httpOnly trek_session cookie from a valid auth code.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://app' }));
|
||||
|
||||
const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } }));
|
||||
vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles }));
|
||||
|
||||
const { oidcSvc } = vi.hoisted(() => ({
|
||||
oidcSvc: {
|
||||
getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(),
|
||||
consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(),
|
||||
findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p,
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/oidcService', () => oidcSvc);
|
||||
|
||||
import { OidcModule } from '../../src/nest/oidc/oidc.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('OIDC e2e (real cookie service)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null });
|
||||
oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' });
|
||||
oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' });
|
||||
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
|
||||
});
|
||||
|
||||
beforeEach(() => { toggles.oidc_login = true; });
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /login is unauthenticated and redirects (302) to the provider', async () => {
|
||||
const res = await request(server).get('/api/auth/oidc/login').redirects(0);
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('https://idp/auth?');
|
||||
});
|
||||
|
||||
it('GET /login returns 403 when SSO is disabled', async () => {
|
||||
toggles.oidc_login = false;
|
||||
const res = await request(server).get('/api/auth/oidc/login');
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'SSO login is disabled.' });
|
||||
});
|
||||
|
||||
it('GET /exchange 400 without a code', async () => {
|
||||
const res = await request(server).get('/api/auth/oidc/exchange');
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Code required' });
|
||||
});
|
||||
|
||||
it('GET /exchange sets the httpOnly trek_session cookie + returns the token', async () => {
|
||||
oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt.value' });
|
||||
const res = await request(server).get('/api/auth/oidc/exchange').query({ code: 'good' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ token: 'jwt.value' });
|
||||
const setCookie = res.headers['set-cookie'] as unknown as string[];
|
||||
expect(setCookie.some((c) => c.startsWith('trek_session=') && /HttpOnly/i.test(c))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Packing module e2e — exercises the migrated /api/trips/:tripId/packing
|
||||
* endpoints through the real JwtAuthGuard against a temp SQLite db. The packing
|
||||
* service, permission check and WebSocket broadcast are mocked; this focuses on
|
||||
* auth, trip-access 404, permission 403, status codes and bodies.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
|
||||
deleteItem: vi.fn(), bulkImport: vi.fn(), reorderItems: vi.fn(), listBags: vi.fn(),
|
||||
createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(), applyTemplate: vi.fn(),
|
||||
saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(),
|
||||
updateCategoryAssignees: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/packingService', () => svc);
|
||||
|
||||
import { PackingModule } from '../../src/nest/packing/packing.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Packing e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
svc.listItems.mockReturnValue([{ id: 1, name: 'Socks' }]);
|
||||
svc.createItem.mockReturnValue({ id: 9, name: 'Socks' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/trips/5/packing');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/packing').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ items: [{ id: 1, name: 'Socks' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
svc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/packing').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 on create with permission', async () => {
|
||||
const res = await request(server).post('/api/trips/5/packing').set('Cookie', sessionCookie(1)).send({ name: 'Socks' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ item: { id: 9, name: 'Socks' } });
|
||||
});
|
||||
|
||||
it('403 on create without permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/packing').set('Cookie', sessionCookie(1)).send({ name: 'Socks' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Places module e2e — exercises the migrated /api/trips/:tripId/places endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. placeService,
|
||||
* journeyService, the permission check, canAccessTrip and the WebSocket
|
||||
* broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { pl } = vi.hoisted(() => ({
|
||||
pl: {
|
||||
listPlaces: vi.fn(), createPlace: vi.fn(), getPlace: vi.fn(), updatePlace: vi.fn(), deletePlace: vi.fn(),
|
||||
deletePlacesMany: vi.fn(), importGpx: vi.fn(), importMapFile: vi.fn(), importGoogleList: vi.fn(),
|
||||
importNaverList: vi.fn(), searchPlaceImage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/placeService', () => pl);
|
||||
|
||||
import { PlacesModule } from '../../src/nest/places/places.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Places e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [PlacesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]);
|
||||
pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' });
|
||||
pl.deletePlacesMany.mockReturnValue([1, 2]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/places')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list', async () => {
|
||||
const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ places: [{ id: 1, name: 'Spot' }] });
|
||||
});
|
||||
|
||||
it('201 create, 403 without permission, 400 over-long name', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ place: { id: 9, name: 'Spot' } });
|
||||
const long = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'x'.repeat(201) });
|
||||
expect(long.status).toBe(400);
|
||||
expect(long.body).toEqual({ error: 'name must be 200 characters or less' });
|
||||
checkPermission.mockReturnValue(false);
|
||||
const forbidden = await request(server).post('/api/trips/5/places').set('Cookie', sessionCookie(1)).send({ name: 'Spot' });
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
|
||||
it('200 (not 201) bulk-delete, 400 on bad ids', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: [1, 2] });
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.body).toEqual({ deleted: [1, 2], count: 2 });
|
||||
const bad = await request(server).post('/api/trips/5/places/bulk-delete').set('Cookie', sessionCookie(1)).send({ ids: ['a'] });
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'ids must be an array of numbers' });
|
||||
});
|
||||
|
||||
it('404 trip when not accessible', async () => {
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/places').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Reservations + accommodations module e2e — exercises both migrated mounts
|
||||
* through the real JwtAuthGuard against a temp SQLite db. The reservation/day/
|
||||
* budget services, the permission check, canAccessTrip and the WebSocket
|
||||
* broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { resv, budget, day } = vi.hoisted(() => ({
|
||||
resv: {
|
||||
verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(),
|
||||
getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), getUpcomingReservations: vi.fn(),
|
||||
},
|
||||
budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() },
|
||||
day: {
|
||||
listAccommodations: vi.fn(), validateAccommodationRefs: vi.fn(), createAccommodation: vi.fn(),
|
||||
getAccommodation: vi.fn(), updateAccommodation: vi.fn(), deleteAccommodation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/reservationService', () => resv);
|
||||
vi.mock('../../src/services/budgetService', () => budget);
|
||||
vi.mock('../../src/services/dayService', () => day);
|
||||
|
||||
import { ReservationsModule } from '../../src/nest/reservations/reservations.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [ReservationsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
resv.listReservations.mockReturnValue([{ id: 1, title: 'Hotel' }]);
|
||||
resv.createReservation.mockReturnValue({ reservation: { id: 9, title: 'Hotel' }, accommodationCreated: false });
|
||||
day.listAccommodations.mockReturnValue([{ id: 1 }]);
|
||||
day.validateAccommodationRefs.mockReturnValue([]);
|
||||
day.createAccommodation.mockReturnValue({ id: 9 });
|
||||
resv.getUpcomingReservations.mockReturnValue([{ id: 1, trip_id: 5, title: 'Flight' }]);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resv.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
canAccessTrip.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie (reservations)', async () => {
|
||||
expect((await request(server).get('/api/trips/5/reservations')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list reservations', async () => {
|
||||
const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ reservations: [{ id: 1, title: 'Hotel' }] });
|
||||
});
|
||||
|
||||
it('401 without a cookie (upcoming feed)', async () => {
|
||||
expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 cross-trip upcoming reservations feed', async () => {
|
||||
const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ reservations: [{ id: 1, trip_id: 5, title: 'Flight' }] });
|
||||
});
|
||||
|
||||
it('404 when trip not accessible (reservations)', async () => {
|
||||
resv.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 create reservation, 400 without title', async () => {
|
||||
const ok = await request(server).post('/api/trips/5/reservations').set('Cookie', sessionCookie(1)).send({ title: 'Hotel' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ reservation: { id: 9, title: 'Hotel' } });
|
||||
const bad = await request(server).post('/api/trips/5/reservations').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(bad.status).toBe(400);
|
||||
expect(bad.body).toEqual({ error: 'Title is required' });
|
||||
});
|
||||
|
||||
it('200 list accommodations + 201 create', async () => {
|
||||
const list = await request(server).get('/api/trips/5/accommodations').set('Cookie', sessionCookie(1));
|
||||
expect(list.status).toBe(200);
|
||||
expect(list.body).toEqual({ accommodations: [{ id: 1 }] });
|
||||
const create = await request(server).post('/api/trips/5/accommodations').set('Cookie', sessionCookie(1)).send({ place_id: 2, start_day_id: 10, end_day_id: 11 });
|
||||
expect(create.status).toBe(201);
|
||||
expect(create.body).toEqual({ accommodation: { id: 9 } });
|
||||
});
|
||||
|
||||
it('404 when trip not accessible (accommodations)', async () => {
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/accommodations').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('400 accommodation create without refs', async () => {
|
||||
const res = await request(server).post('/api/trips/5/accommodations').set('Cookie', sessionCookie(1)).send({ place_id: 2 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'place_id, start_day_id, and end_day_id are required' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Settings e2e — exercises the migrated /api/settings endpoints through the real
|
||||
* JwtAuthGuard against a temp SQLite db. The settings service is mocked; this
|
||||
* focuses on auth, the 400 guards, the masked-sentinel no-op and status codes.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { settingsSvc } = vi.hoisted(() => ({
|
||||
settingsSvc: { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/settingsService', () => settingsSvc);
|
||||
|
||||
import { SettingsModule } from '../../src/nest/settings/settings.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Settings e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [SettingsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
|
||||
settingsSvc.bulkUpsertSettings.mockReturnValue(2);
|
||||
});
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/settings')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list with a session', async () => {
|
||||
settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' });
|
||||
const res = await request(server).get('/api/settings').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ settings: { theme: 'dark' } });
|
||||
});
|
||||
|
||||
it('PUT 400 without a key', async () => {
|
||||
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ value: 'x' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Key is required' });
|
||||
});
|
||||
|
||||
it('PUT no-ops on the masked sentinel', async () => {
|
||||
const res = await request(server).put('/api/settings').set('Cookie', sessionCookie(1)).send({ key: 'secret', value: '••••••••' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, key: 'secret', unchanged: true });
|
||||
expect(settingsSvc.upsertSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('POST /bulk 200', async () => {
|
||||
settingsSvc.bulkUpsertSettings.mockReturnValue(2);
|
||||
const res = await request(server).post('/api/settings/bulk').set('Cookie', sessionCookie(1)).send({ settings: { a: 1, b: 2 } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, updated: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Share-link e2e — exercises the migrated /api/trips/:tripId/share-link and the
|
||||
* public /api/shared/:token endpoints through the real JwtAuthGuard against a
|
||||
* temp SQLite db. The share service + permission check are mocked; this focuses
|
||||
* on auth, trip-access 404, permission 403, the create-201-vs-update-200 split
|
||||
* and the unguarded public read.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db, canAccessTrip } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp, canAccessTrip: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, canAccessTrip, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { shareSvc } = vi.hoisted(() => ({
|
||||
shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn(), getSharedPlacePhotoPath: vi.fn() },
|
||||
}));
|
||||
vi.mock('../../src/services/shareService', () => shareSvc);
|
||||
|
||||
import { ShareModule } from '../../src/nest/share/share.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Share-link e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [ShareModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
expect((await request(server).get('/api/trips/5/share-link')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('201 on first create, 200 on a subsequent update', async () => {
|
||||
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: true });
|
||||
const created = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({ share_map: true });
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.body).toEqual({ token: 't' });
|
||||
|
||||
shareSvc.createOrUpdateShareLink.mockReturnValueOnce({ token: 't', created: false });
|
||||
const updated = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body).toEqual({ token: 't' });
|
||||
});
|
||||
|
||||
it('403 without share_manage', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/share-link').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
canAccessTrip.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/share-link').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('public shared read is unguarded (200, no cookie)', async () => {
|
||||
const res = await request(server).get('/api/shared/tok');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ trip: { id: 9 } });
|
||||
});
|
||||
|
||||
it('public shared read 404 for an invalid token', async () => {
|
||||
shareSvc.getSharedTripData.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/shared/bad');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Invalid or expired link' });
|
||||
});
|
||||
|
||||
describe('public place-photo proxy (/api/shared/:token/place-photo/:placeId/bytes)', () => {
|
||||
const photoFile = path.join(os.tmpdir(), 'trek-share-photo.e2e.jpg');
|
||||
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG-ish header
|
||||
|
||||
beforeAll(() => fs.writeFileSync(photoFile, photoBytes));
|
||||
afterAll(() => { try { fs.unlinkSync(photoFile); } catch { /* ignore */ } });
|
||||
|
||||
it('streams cached bytes with no cookie (unguarded) for a valid token + place', async () => {
|
||||
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(photoFile);
|
||||
const res = await request(server).get('/api/shared/tok/place-photo/ChIJabc/bytes');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(res.headers['cache-control']).toContain('immutable');
|
||||
expect(Buffer.from(res.body)).toEqual(photoBytes);
|
||||
expect(shareSvc.getSharedPlacePhotoPath).toHaveBeenCalledWith('tok', 'ChIJabc');
|
||||
});
|
||||
|
||||
it('404 when the token/place does not resolve to a cached photo', async () => {
|
||||
shareSvc.getSharedPlacePhotoPath.mockReturnValueOnce(null);
|
||||
const res = await request(server).get('/api/shared/bad/place-photo/ChIJabc/bytes');
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* System-notices module e2e — exercises the migrated /api/system-notices
|
||||
* endpoints through the real JwtAuthGuard against a temp SQLite db. The notices
|
||||
* service is mocked so the test doesn't depend on the static registry or the
|
||||
* dismissal tables; it focuses on routing, auth, status codes and bodies.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() }));
|
||||
vi.mock('../../src/systemNotices/service', () => ({
|
||||
getActiveNoticesFor: mockGetActive,
|
||||
dismissNotice: mockDismiss,
|
||||
}));
|
||||
|
||||
import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const notice = {
|
||||
id: 'welcome', display: 'modal', severity: 'info',
|
||||
titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true,
|
||||
};
|
||||
|
||||
describe('System-notices e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [SystemNoticesModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/system-notices/active');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('200 with the active notices for the user', async () => {
|
||||
mockGetActive.mockReturnValue([notice]);
|
||||
const res = await request(server).get('/api/system-notices/active').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([notice]);
|
||||
expect(mockGetActive).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('204 with no body on a successful dismiss', async () => {
|
||||
mockDismiss.mockReturnValue(true);
|
||||
const res = await request(server).post('/api/system-notices/welcome/dismiss').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.body).toEqual({});
|
||||
expect(res.text).toBe('');
|
||||
expect(mockDismiss).toHaveBeenCalledWith(1, 'welcome');
|
||||
});
|
||||
|
||||
it('404 { error: NOTICE_NOT_FOUND } when the id is unknown', async () => {
|
||||
mockDismiss.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/system-notices/nope/dismiss').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'NOTICE_NOT_FOUND' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Tags module e2e — exercises the migrated /api/tags endpoints through the real
|
||||
* JwtAuthGuard against a temp SQLite db. tagService is mocked; tags are
|
||||
* user-scoped (no admin gate), so a normal authenticated user can do everything.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mocks } = vi.hoisted(() => ({
|
||||
mocks: {
|
||||
listTags: vi.fn(),
|
||||
createTag: vi.fn(),
|
||||
getTagByIdAndUser: vi.fn(),
|
||||
updateTag: vi.fn(),
|
||||
deleteTag: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/tagService', () => mocks);
|
||||
|
||||
import { TagsModule } from '../../src/nest/tags/tags.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
const tag = { id: 1, user_id: 1, name: 'Beach', color: '#10b981' };
|
||||
|
||||
describe('Tags e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mocks.listTags.mockReturnValue([tag]);
|
||||
mocks.createTag.mockReturnValue(tag);
|
||||
mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined));
|
||||
mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/tags');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list scoped to the user', async () => {
|
||||
const res = await request(server).get('/api/tags').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ tags: [tag] });
|
||||
expect(mocks.listTags).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('201 on create', async () => {
|
||||
const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({ name: 'Beach', color: '#10b981' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ tag });
|
||||
expect(mocks.createTag).toHaveBeenCalledWith(1, 'Beach', '#10b981');
|
||||
});
|
||||
|
||||
it('400 on create without a name', async () => {
|
||||
const res = await request(server).post('/api/tags').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Tag name is required' });
|
||||
});
|
||||
|
||||
it('200 on update of an owned tag', async () => {
|
||||
const res = await request(server).put('/api/tags/1').set('Cookie', sessionCookie(1)).send({ name: 'Hike' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ tag: { ...tag, name: 'Hike' } });
|
||||
});
|
||||
|
||||
it('404 on update of a tag the user does not own', async () => {
|
||||
const res = await request(server).put('/api/tags/9').set('Cookie', sessionCookie(1)).send({ name: 'X' });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Tag not found' });
|
||||
});
|
||||
|
||||
it('200 on delete of an owned tag', async () => {
|
||||
const res = await request(server).delete('/api/tags/1').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true });
|
||||
expect(mocks.deleteTag).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* To-do module e2e — exercises the migrated /api/trips/:tripId/todo endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. todoService, the
|
||||
* permission check and the WebSocket broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(),
|
||||
deleteItem: vi.fn(), reorderItems: vi.fn(), getCategoryAssignees: vi.fn(), updateCategoryAssignees: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/todoService', () => svc);
|
||||
|
||||
import { TodoModule } from '../../src/nest/todo/todo.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('To-do e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [TodoModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
svc.listItems.mockReturnValue([{ id: 1, name: 'Book hotel' }]);
|
||||
svc.createItem.mockReturnValue({ id: 9, name: 'Book hotel' });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/trips/5/todo');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/5/todo').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ items: [{ id: 1, name: 'Book hotel' }] });
|
||||
});
|
||||
|
||||
it('404 when the trip is not accessible', async () => {
|
||||
svc.verifyTripAccess.mockReturnValue(undefined);
|
||||
const res = await request(server).get('/api/trips/5/todo').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('201 on create with permission', async () => {
|
||||
const res = await request(server).post('/api/trips/5/todo').set('Cookie', sessionCookie(1)).send({ name: 'Book hotel' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toEqual({ item: { id: 9, name: 'Book hotel' } });
|
||||
});
|
||||
|
||||
it('403 on create without permission', async () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const res = await request(server).post('/api/trips/5/todo').set('Cookie', sessionCookie(1)).send({ name: 'Book hotel' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'No permission' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Trips module e2e — exercises the migrated /api/trips aggregate-root endpoints
|
||||
* through the real JwtAuthGuard against a temp SQLite db. tripService, the bundle
|
||||
* list-services, auditLog, demo, the permission check, canAccessTrip and the
|
||||
* WebSocket broadcast are mocked.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
tmp.exec('CREATE TABLE trips (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT);');
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() }));
|
||||
vi.mock('../../src/db/database', () => ({
|
||||
db, canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {},
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() }));
|
||||
vi.mock('../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { tripSvc } = vi.hoisted(() => ({
|
||||
tripSvc: {
|
||||
listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(),
|
||||
getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(),
|
||||
listMembers: vi.fn(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(),
|
||||
verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {}, TRIP_SELECT: 'SELECT',
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/tripService', () => tripSvc);
|
||||
vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), listAccommodations: () => [] }));
|
||||
vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] }));
|
||||
vi.mock('../../src/services/packingService', () => ({ listItems: () => [] }));
|
||||
vi.mock('../../src/services/todoService', () => ({ listItems: () => [] }));
|
||||
vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] }));
|
||||
vi.mock('../../src/services/reservationService', () => ({ listReservations: () => [] }));
|
||||
vi.mock('../../src/services/fileService', () => ({ listFiles: () => [] }));
|
||||
|
||||
import { TripsModule } from '../../src/nest/trips/trips.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Trips e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]);
|
||||
tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 });
|
||||
tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined));
|
||||
tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
canAccessTrip.mockReturnValue({ user_id: 1 });
|
||||
checkPermission.mockReturnValue(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a cookie', async () => {
|
||||
expect((await request(server).get('/api/trips')).status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 list', async () => {
|
||||
const res = await request(server).get('/api/trips').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ trips: [{ id: 1, title: 'T' }] });
|
||||
});
|
||||
|
||||
it('201 create, 403 without permission', async () => {
|
||||
const ok = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' });
|
||||
expect(ok.status).toBe(201);
|
||||
expect(ok.body).toEqual({ trip: { id: 9 } });
|
||||
checkPermission.mockReturnValue(false);
|
||||
const forbidden = await request(server).post('/api/trips').set('Cookie', sessionCookie(1)).send({ title: 'T' });
|
||||
expect(forbidden.status).toBe(403);
|
||||
});
|
||||
|
||||
it('404 on a missing trip', async () => {
|
||||
const res = await request(server).get('/api/trips/77').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Trip not found' });
|
||||
});
|
||||
|
||||
it('200 bundle for an accessible trip', async () => {
|
||||
const res = await request(server).get('/api/trips/9/bundle').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ trip: { id: 9 }, days: [], members: [{ id: 1 }] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Vacay module e2e — exercises the migrated /api/addons/vacay endpoints through
|
||||
* the real JwtAuthGuard against a temp SQLite db. vacayService is mocked; this
|
||||
* focuses on auth, status codes (POSTs stay 200) and a couple of validation/403
|
||||
* bodies.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
getPlanData: vi.fn(), getActivePlanId: vi.fn(), getActivePlan: vi.fn(), updatePlan: vi.fn(),
|
||||
addHolidayCalendar: vi.fn(), updateHolidayCalendar: vi.fn(), deleteHolidayCalendar: vi.fn(),
|
||||
getPlanUsers: vi.fn(), setUserColor: vi.fn(), sendInvite: vi.fn(), acceptInvite: vi.fn(),
|
||||
declineInvite: vi.fn(), cancelInvite: vi.fn(), dissolvePlan: vi.fn(), getAvailableUsers: vi.fn(),
|
||||
listYears: vi.fn(), addYear: vi.fn(), deleteYear: vi.fn(), getEntries: vi.fn(),
|
||||
toggleEntry: vi.fn(), toggleCompanyHoliday: vi.fn(), getStats: vi.fn(), updateStats: vi.fn(),
|
||||
getCountries: vi.fn(), getHolidays: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../src/services/vacayService', () => svc);
|
||||
|
||||
import { VacayModule } from '../../src/nest/vacay/vacay.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Vacay e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
svc.getActivePlanId.mockReturnValue(10);
|
||||
svc.getActivePlan.mockReturnValue({ id: 10 });
|
||||
svc.getPlanUsers.mockReturnValue([{ id: 1 }]);
|
||||
svc.getPlanData.mockReturnValue({ plan: { id: 10 } });
|
||||
svc.toggleEntry.mockReturnValue({ action: 'added' });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/addons/vacay/plan');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('200 plan for an authenticated user', async () => {
|
||||
const res = await request(server).get('/api/addons/vacay/plan').set('Cookie', sessionCookie(1));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ plan: { id: 10 } });
|
||||
});
|
||||
|
||||
it('200 (not 201) on POST entries/toggle, forwarding the socket id', async () => {
|
||||
const res = await request(server).post('/api/addons/vacay/entries/toggle')
|
||||
.set('Cookie', sessionCookie(1)).set('X-Socket-Id', 'sock-7').send({ date: '2026-07-01' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ action: 'added' });
|
||||
expect(svc.toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 'sock-7');
|
||||
});
|
||||
|
||||
it('400 on entries/toggle without a date', async () => {
|
||||
const res = await request(server).post('/api/addons/vacay/entries/toggle').set('Cookie', sessionCookie(1)).send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'date required' });
|
||||
});
|
||||
|
||||
it('403 on color for a user not in the plan', async () => {
|
||||
const res = await request(server).put('/api/addons/vacay/color').set('Cookie', sessionCookie(1)).send({ color: '#fff', target_user_id: 99 });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: 'User not in plan' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Weather module e2e — exercises the migrated /api/weather endpoints through the
|
||||
* real JwtAuthGuard against a temp SQLite db (seeded via the shared harness).
|
||||
* The weather service is mocked so no real Open-Meteo calls happen.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { createTempDb, seedUser, sessionCookie } from './harness';
|
||||
|
||||
const { db } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const tmp = new Database(':memory:');
|
||||
tmp.exec('PRAGMA journal_mode = WAL');
|
||||
tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`);
|
||||
return { db: tmp };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { mockGet, mockGetDetailed } = vi.hoisted(() => ({ mockGet: vi.fn(), mockGetDetailed: vi.fn() }));
|
||||
vi.mock('../../src/services/weatherService', async (importActual) => {
|
||||
const actual = await importActual<typeof import('../../src/services/weatherService')>();
|
||||
return { ...actual, getWeather: mockGet, getDetailedWeather: mockGetDetailed };
|
||||
});
|
||||
|
||||
import { WeatherModule } from '../../src/nest/weather/weather.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
|
||||
describe('Weather e2e (real auth guard + temp SQLite)', () => {
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({ imports: [WeatherModule] }).compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedUser(db as never, { id: 1 });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
mockGet.mockResolvedValue({ temp: 21, main: 'Clear', description: 'Klar', type: 'current' });
|
||||
mockGetDetailed.mockResolvedValue({ temp: 20, main: 'Rain', description: 'Regen', type: 'forecast', hourly: [] });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('401 { error, code } without a session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather').query({ lat: '1', lng: '2' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('401 with an invalid token', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', 'trek_session=not-a-jwt').query({ lat: '1', lng: '2' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body).toEqual({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('400 when authenticated but lat/lng missing', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lng: '2' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body).toEqual({ error: 'Latitude and longitude are required' });
|
||||
});
|
||||
|
||||
it('200 with a valid session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather').set('Cookie', sessionCookie(1)).query({ lat: '52.5', lng: '13.4' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ temp: 21, main: 'Clear', type: 'current' });
|
||||
});
|
||||
|
||||
it('200 on /detailed with a valid session cookie', async () => {
|
||||
const res = await request(server).get('/api/weather/detailed').set('Cookie', sessionCookie(1)).query({ lat: '1', lng: '2', date: '2026-07-01' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ type: 'forecast' });
|
||||
});
|
||||
});
|
||||
@@ -17,8 +17,11 @@
|
||||
*/
|
||||
|
||||
import Database from 'better-sqlite3';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { AuthPublicController } from '../../src/nest/auth/auth-public.controller';
|
||||
import type { RateLimitService } from '../../src/nest/auth/rate-limit.service';
|
||||
|
||||
// Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset).
|
||||
// Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons,
|
||||
@@ -117,7 +120,7 @@ const DEFAULT_CATEGORIES = [
|
||||
|
||||
const DEFAULT_ADDONS = [
|
||||
{ id: 'packing', name: 'Packing List', description: 'Pack your bags', type: 'trip', icon: 'ListChecks', enabled: 1, sort_order: 0 },
|
||||
{ id: 'budget', name: 'Budget Planner', description: 'Track expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||
{ id: 'budget', name: 'Costs', description: 'Track and split trip expenses', type: 'trip', icon: 'Wallet', enabled: 1, sort_order: 1 },
|
||||
{ id: 'documents', name: 'Documents', description: 'Manage travel documents', type: 'trip', icon: 'FileText', enabled: 1, sort_order: 2 },
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Vacation day planner', type: 'global', icon: 'CalendarDays',enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'Visited countries map', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
@@ -238,9 +241,28 @@ export function buildDbMock(testDb: Database.Database) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the Nest per-IP rate-limit buckets between tests — the buildApp() drop-in
|
||||
* for the legacy `loginAttempts.clear(); mfaAttempts.clear()`.
|
||||
*
|
||||
* The Nest auth path keeps its rate-limit state in a RateLimitService instance that
|
||||
* lives inside the AuthModule injector (shared by AuthPublicController/AuthController
|
||||
* for the login/mfa/forgot buckets). The same class is ALSO provided separately in
|
||||
* OauthModule (its own instance, distinct oauth_* buckets), so a plain
|
||||
* app.get(RateLimitService) is ambiguous and may hand back the wrong instance — we
|
||||
* resolve the auth controller and clear the limiter it actually uses.
|
||||
*/
|
||||
export function resetRateLimits(app: INestApplication): void {
|
||||
const ctrl = app.get(AuthPublicController, { strict: false }) as unknown as { rl: RateLimitService };
|
||||
ctrl.rl.reset();
|
||||
}
|
||||
|
||||
/** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */
|
||||
export const TEST_CONFIG = {
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,59 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Stub the admin-1 GeoJSON download so /regions/geo is deterministic and never
|
||||
// hits the real network (the un-stubbed fetch of a ~4600-feature file from
|
||||
// raw.githubusercontent.com is what made ATLAS-013 hang/time out under load).
|
||||
// Any other outbound fetch (e.g. background reverse-geocoding) returns empty so
|
||||
// no test depends on live network.
|
||||
vi.stubGlobal('fetch', async (url: unknown) => {
|
||||
if (String(url).includes('natural-earth-vector')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{ type: 'Feature', properties: { iso_a2: 'DE' }, geometry: { type: 'Point', coordinates: [10, 51] } },
|
||||
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: { type: 'Point', coordinates: [2, 47] } },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { authenticator } from 'otplib';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -46,31 +47,38 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
// Reset rate limiter state between tests so they don't interfere
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -467,6 +475,31 @@ describe('Forced MFA policy', () => {
|
||||
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('AUTH-020 — require_mfa guards nested Nest addon controllers, not just top-level routes', async () => {
|
||||
// The global MFA middleware runs ahead of the Express→Nest dispatch, so it
|
||||
// must block the deeper trip-scoped controllers (budget/packing/todo) too —
|
||||
// not only /api/trips. A regression that only guarded top-level paths would
|
||||
// leave every addon endpoint reachable without MFA.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
for (const path of [`/api/trips/${trip.id}/budget`, `/api/trips/${trip.id}/packing`, `/api/trips/${trip.id}/todo`]) {
|
||||
const res = await request(app).get(path).set(authHeader(user.id));
|
||||
expect(res.status, `${path} must be MFA-gated`).toBe(403);
|
||||
expect(res.body.code).toBe('MFA_REQUIRED');
|
||||
}
|
||||
});
|
||||
|
||||
it('AUTH-020 — MFA-enabled user reaches nested Nest addon controllers under require_mfa', async () => {
|
||||
const { user } = createUserWithMfa(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set(authHeader(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -805,6 +838,18 @@ describe('Rate limiting', () => {
|
||||
}
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
|
||||
it('AUTH-019 — reset-password endpoint rate-limits after 5 attempts (parity with the legacy resetLimiter)', async () => {
|
||||
let lastStatus = 0;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const res = await request(app)
|
||||
.post('/api/auth/reset-password')
|
||||
.send({ token: 'badtoken', new_password: 'NewPassw0rd!' });
|
||||
lastStatus = res.status;
|
||||
if (lastStatus === 429) break;
|
||||
}
|
||||
expect(lastStatus).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -39,7 +40,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Mock filesystem-dependent service functions to avoid real disk I/O in tests
|
||||
vi.mock('../../src/services/backupService', async () => {
|
||||
@@ -69,32 +75,34 @@ vi.mock('../../src/services/backupService', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createAdmin, createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as backupService from '../../src/services/backupService';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* BOOTSTRAP / F6 — boots the unified production bootstrap (buildApp) and asserts
|
||||
* the whole shell is intact on the single NestJS instance now that Express is gone:
|
||||
* the global security pipeline (helmet/CSP), the /uploads platform routes, the
|
||||
* migrated /api domains (with the JWT guard), the /api/health + /api/addons
|
||||
* platform/inline endpoints, and (in production) HSTS. This is the test that proves
|
||||
* server/src/bootstrap.ts + index.ts serve everything correctly without the legacy app.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
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-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
|
||||
describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () => {
|
||||
let app: INestApplication;
|
||||
let instance: import('express').Application;
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
resetTestDb(testDb);
|
||||
app = await buildApp();
|
||||
instance = app.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
it('BOOT-001 — GET /api/health returns 200 { status: ok } (platform transport on Nest)', async () => {
|
||||
const res = await request(instance).get('/api/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.headers['cache-control']).toContain('no-store');
|
||||
});
|
||||
|
||||
it('BOOT-002 — the global security pipeline (helmet) is applied', async () => {
|
||||
const res = await request(instance).get('/api/health');
|
||||
// helmet defaults — proof applyGlobalMiddleware ran on the Nest instance.
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-security-policy']).toBeDefined();
|
||||
});
|
||||
|
||||
it('BOOT-003 — public /api/config is reachable without auth (migrated Nest domain)', async () => {
|
||||
const res = await request(instance).get('/api/config');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it('BOOT-004 — a protected /api domain rejects an anonymous request (JWT guard wired)', async () => {
|
||||
const res = await request(instance).get('/api/trips');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('BOOT-005 — /uploads/files is blocked without auth (platform uploads on Nest)', async () => {
|
||||
const res = await request(instance).get('/uploads/files/anything.bin');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('BOOT-006 — GET /api/addons works end-to-end (guard → Nest AddonsController)', async () => {
|
||||
const anon = await request(instance).get('/api/addons');
|
||||
expect(anon.status).toBe(401);
|
||||
|
||||
const { user } = createUser(testDb);
|
||||
const res = await request(instance).get('/api/addons').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.addons)).toBe(true);
|
||||
});
|
||||
|
||||
it('BOOT-007 — HSTS is advertised when NODE_ENV=production', async () => {
|
||||
const prev = process.env.NODE_ENV;
|
||||
process.env.NODE_ENV = 'production';
|
||||
let prodApp: INestApplication | undefined;
|
||||
try {
|
||||
prodApp = await buildApp();
|
||||
const res = await request(prodApp.getHttpAdapter().getInstance()).get('/api/health');
|
||||
expect(res.headers['strict-transport-security']).toContain('max-age=');
|
||||
} finally {
|
||||
if (prodApp) await prodApp.close();
|
||||
if (prev === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = prev;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -342,10 +350,12 @@ describe('Budget summary and settlement', () => {
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ user_ids: [user.id, user2.id] });
|
||||
// New model: who actually paid is recorded as an explicit payer (amount in
|
||||
// the expense currency), not a per-member "paid" toggle.
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/members/${user.id}/paid`)
|
||||
.put(`/api/trips/${trip.id}/budget/${item.id}/payers`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ paid: true });
|
||||
.send({ payers: [{ user_id: user.id, amount: 60 }] });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/budget/settlement`)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -30,30 +31,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -40,7 +41,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Partially mock collabService to make fetchLinkPreview controllable
|
||||
vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
@@ -51,34 +57,36 @@ vi.mock('../../src/services/collabService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as collabService from '../../src/services/collabService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
// Ensure uploads/files dir exists for collab file uploads
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Day reorder + insert integration tests (#589) — exercises the real
|
||||
* dayService against the real schema. Covers: position renumber, dates pinned
|
||||
* to slots while content rides along by id, booking-date re-stamp, permutation
|
||||
* validation, the accommodation-inversion guard, and insert (dated + dateless).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
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');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createDay, createDayAssignment, createReservation, createDayAccommodation } from '../helpers/factories';
|
||||
import { reorderDays, insertDay, DayReorderError } from '../../src/services/dayService';
|
||||
|
||||
let userId: number;
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
userId = createUser(testDb).user.id;
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
const orderedDays = (tripId: number) =>
|
||||
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as
|
||||
{ id: number; day_number: number; date: string | null }[];
|
||||
|
||||
describe('reorderDays', () => {
|
||||
it('permutes positions, pins dates to slots, and content rides along by id', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAssignment(testDb, d2.id, place.id); // place sits on day 2
|
||||
|
||||
// Move day 2 to the front: [d2, d1, d3]
|
||||
reorderDays(trip.id, [d2.id, d1.id, d3.id]);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]);
|
||||
// Dates stay pinned to their calendar slots
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03']);
|
||||
// The place rides along with its day row (still attached to d2.id, now at slot 1)
|
||||
const onD2 = testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(d2.id);
|
||||
expect(onD2).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id);
|
||||
|
||||
reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot
|
||||
|
||||
const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string };
|
||||
expect(r.reservation_time).toBe('2026-03-01T19:00');
|
||||
});
|
||||
|
||||
it('rejects an orderedIds list that is not a permutation of the trip days', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2] = orderedDays(trip.id);
|
||||
expect(() => reorderDays(trip.id, [d1.id, d2.id])).toThrow(DayReorderError);
|
||||
});
|
||||
|
||||
it('blocks a move that would make an accommodation end before it starts, and rolls back', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2
|
||||
|
||||
// Put the start day (d1) after the end day (d2): [d2, d3, d1]
|
||||
expect(() => reorderDays(trip.id, [d2.id, d3.id, d1.id])).toThrow(DayReorderError);
|
||||
|
||||
// Transaction rolled back: original order intact
|
||||
expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertDay', () => {
|
||||
it('inserts an empty day at a position on a dateless trip and shifts the rest', () => {
|
||||
const trip = createTrip(testDb, userId);
|
||||
const d1 = createDay(testDb, trip.id);
|
||||
const d2 = createDay(testDb, trip.id);
|
||||
const d3 = createDay(testDb, trip.id);
|
||||
|
||||
const created = insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after[0].date).toBeNull();
|
||||
expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
});
|
||||
|
||||
it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
|
||||
const created = insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
|
||||
// Old content shifted down a slot
|
||||
expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
// Trip range extended by one day
|
||||
const t = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
|
||||
expect(t.end_date).toBe('2026-03-04');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// In-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -38,20 +39,33 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); });
|
||||
afterAll(() => { testDb.close(); });
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); });
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List days (DAY-001, DAY-002)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -42,26 +43,33 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
|
||||
// Ensure uploads/files dir exists
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
// Seed allowed_file_types to include common types (wildcard)
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
@@ -69,13 +77,13 @@ beforeAll(() => {
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
// Re-seed allowed_file_types after reset
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,7 +39,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS.
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
@@ -64,28 +70,30 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -43,6 +44,10 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({
|
||||
broadcast: vi.fn(),
|
||||
@@ -55,10 +60,10 @@ vi.mock('../../src/services/memories/immichService', () => ({
|
||||
getImmichCredentials: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import {
|
||||
createUser,
|
||||
createAdmin,
|
||||
@@ -68,23 +73,30 @@ import {
|
||||
addJourneyContributor,
|
||||
} from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
// Enable the journey addon
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// List journeys (JOURNEY-INT-001, 002)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,7 +39,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for
|
||||
// URLs that look internal); individual tests override with mockResolvedValueOnce.
|
||||
@@ -53,29 +59,31 @@ vi.mock('../../src/services/mapsService', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as mapsService from '../../src/services/mapsService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,33 +39,40 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createMcpToken } from '../helpers/factories';
|
||||
import { closeMcpSessions } from '../../src/mcp/index';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
closeMcpSessions();
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,8 +37,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// ── SSRF guard mock — routes all Immich API calls to fake responses ───────────
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
@@ -164,31 +169,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const IMMICH = '/api/integrations/memories/immich';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Connection status ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +39,10 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
@@ -190,31 +195,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { safeFetch } from '../../src/utils/ssrfGuard';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const SYNO = '/api/integrations/memories/synologyphotos';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -36,8 +37,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../src/utils/ssrfGuard')>('../../src/utils/ssrfGuard');
|
||||
return {
|
||||
@@ -47,30 +52,34 @@ vi.mock('../../src/utils/ssrfGuard', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
const BASE = '/api/integrations/memories/unified';
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -95,33 +103,36 @@ describe('Photo endpoint auth', () => {
|
||||
|
||||
describe('Force HTTPS redirect', () => {
|
||||
it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => {
|
||||
// createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance
|
||||
// applyGlobalMiddleware reads FORCE_HTTPS when buildApp() composes the app, so
|
||||
// we need a fresh Nest instance built with the flag set.
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
let httpsApp: INestApplication | undefined;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
httpsApp = await buildApp();
|
||||
const res = await request(httpsApp.getHttpAdapter().getInstance())
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
} finally {
|
||||
if (httpsApp) await httpsApp.close();
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/addons')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(301);
|
||||
});
|
||||
|
||||
it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => {
|
||||
process.env.FORCE_HTTPS = 'true';
|
||||
let httpsApp: Express;
|
||||
let httpsApp: INestApplication | undefined;
|
||||
try {
|
||||
httpsApp = createApp();
|
||||
httpsApp = await buildApp();
|
||||
const res = await request(httpsApp.getHttpAdapter().getInstance())
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
} finally {
|
||||
if (httpsApp) await httpsApp.close();
|
||||
delete process.env.FORCE_HTTPS;
|
||||
}
|
||||
const res = await request(httpsApp)
|
||||
.get('/api/health')
|
||||
.set('X-Forwarded-Proto', 'http');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
});
|
||||
|
||||
it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -38,8 +39,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/notifications')>();
|
||||
return {
|
||||
@@ -49,28 +54,30 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -37,6 +38,10 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
|
||||
const { isAddonEnabledMock } = vi.hoisted(() => {
|
||||
@@ -56,16 +61,16 @@ vi.mock('../../src/services/notifications', async (importOriginal) => {
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
// PKCE helpers
|
||||
function makePkce() {
|
||||
@@ -74,19 +79,33 @@ function makePkce() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// A7: under the unified Nest app the adminService mock only reaches the directly
|
||||
// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the
|
||||
// addon state through its own import that the Nest module graph loads unmocked,
|
||||
// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is
|
||||
// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke.
|
||||
function setMcpEnabled(enabled: boolean) {
|
||||
isAddonEnabledMock.mockReturnValue(enabled);
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
|
||||
).run(enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
isAddonEnabledMock.mockReturnValue(true);
|
||||
resetRateLimits(nestApp);
|
||||
setMcpEnabled(true);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -156,7 +175,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||
@@ -511,7 +530,7 @@ describe('POST /oauth/revoke', () => {
|
||||
|
||||
describe('GET /api/oauth/authorize/validate', () => {
|
||||
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
@@ -697,7 +716,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-029 — 403 when MCP disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -772,7 +791,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
|
||||
describe('Client CRUD — /api/oauth/clients', () => {
|
||||
it('OAUTH-033 — GET returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -809,7 +828,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-036 — POST returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -859,7 +878,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
|
||||
describe('Sessions — /api/oauth/sessions', () => {
|
||||
it('OAUTH-040 — GET returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -927,7 +946,7 @@ describe('Sessions — /api/oauth/sessions', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -952,13 +971,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
|
||||
|
||||
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
|
||||
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app).get('/.well-known/oauth-authorization-server');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => {
|
||||
isAddonEnabledMock.mockReturnValue(false);
|
||||
setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/revoke')
|
||||
.send({ token: 'x', client_id: 'y', client_secret: 'z' });
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ── DB mock (inline vi.hoisted pattern) ──────────────────────────────────────
|
||||
|
||||
@@ -34,7 +35,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// ── Mock only the HTTP-calling functions from oidcService ────────────────────
|
||||
vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
@@ -52,12 +58,11 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as oidcService from '../../src/services/oidcService';
|
||||
|
||||
const mockDiscover = vi.mocked(oidcService.discover);
|
||||
@@ -71,17 +76,19 @@ const MOCK_DISCOVERY_DOC = {
|
||||
userinfo_endpoint: 'https://oidc.example.com/userinfo',
|
||||
};
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set OIDC environment variables for each test
|
||||
@@ -98,7 +105,8 @@ afterEach(() => {
|
||||
delete process.env.APP_URL;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -157,12 +165,13 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
sub: 'sub-alice-123',
|
||||
email: 'alice@example.com',
|
||||
name: 'Alice',
|
||||
email_verified: true, // verified IdP — required to auto-link onto the existing account
|
||||
});
|
||||
|
||||
// Create a valid state token
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=authcode123&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
@@ -178,9 +187,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'New User',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=code999&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
@@ -215,9 +224,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ _ok: false, _status: 400 });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=badcode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=token_failed');
|
||||
@@ -227,9 +236,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', _ok: true, _status: 200 }); // no id_token
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=no_id_token');
|
||||
@@ -240,9 +249,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'bad.id.token', _ok: true, _status: 200 });
|
||||
mockVerifyIdToken.mockResolvedValueOnce({ ok: false, error: 'signature_or_claim_mismatch: invalid signature' });
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=id_token_invalid');
|
||||
@@ -258,9 +267,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'Alice',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=subject_mismatch');
|
||||
@@ -281,9 +290,9 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
name: 'Blocked',
|
||||
});
|
||||
|
||||
const state = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
const { state } = oidcService.createState('http://localhost:3001/api/auth/oidc/callback');
|
||||
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`);
|
||||
const res = await request(app).get(`/api/auth/oidc/callback?code=anycode&state=${state}`).set('Cookie', `trek_oidc_state=${state}`);
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.location).toContain('oidc_error=registration_disabled');
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -440,8 +448,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017 — POST /save-as-template saves packing list as a template', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
it('PACK-017 — POST /save-as-template saves packing list as a template (admin)', async () => {
|
||||
const { user } = createUser(testDb, { role: 'admin' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
// Add an item so the trip has something to save
|
||||
@@ -457,8 +465,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
expect(res.body.template.name).toBe('My Summer Template');
|
||||
});
|
||||
|
||||
it('PACK-017b — POST /save-as-template without name returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
it('PACK-017b — POST /save-as-template without name returns 400 (admin)', async () => {
|
||||
const { user } = createUser(testDb, { role: 'admin' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -470,8 +478,8 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
it('PACK-017c — POST /save-as-template when trip has no items returns 400 (admin)', async () => {
|
||||
const { user } = createUser(testDb, { role: 'admin' });
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -482,4 +490,37 @@ describe('Packing — apply-template, bag members, save-as-template', () => {
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('PACK-017d — POST /save-as-template is forbidden for non-admins (403)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createPackingItem(testDb, trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ name: 'My Summer Template' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toBe('Admin access required');
|
||||
});
|
||||
|
||||
it('PACK-017e — GET /packing/templates lists templates for a trip member', async () => {
|
||||
const { user: admin } = createUser(testDb, { role: 'admin' });
|
||||
const trip = createTrip(testDb, admin.id);
|
||||
createPackingItem(testDb, trip.id);
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/packing/save-as-template`)
|
||||
.set('Cookie', authCookie(admin.id))
|
||||
.send({ name: 'Shared Template' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/packing/templates`)
|
||||
.set('Cookie', authCookie(admin.id));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(Array.isArray(res.body.templates)).toBe(true);
|
||||
expect(res.body.templates.some((t: { name: string }) => t.name === 'Shared Template')).toBe(true);
|
||||
expect(res.body.templates[0]).toHaveProperty('item_count');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -41,7 +42,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/services/placeService')>();
|
||||
return {
|
||||
@@ -51,36 +57,38 @@ vi.mock('../../src/services/placeService', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placeService from '../../src/services/placeService';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx');
|
||||
const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml');
|
||||
const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml');
|
||||
const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml');
|
||||
const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -36,32 +37,39 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -42,35 +43,42 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -30,30 +31,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,30 +36,39 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import * as placePhotoCache from '../../src/services/placePhotoCache';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
@@ -343,3 +353,78 @@ describe('Shared trip — ordering parity (issue #981)', () => {
|
||||
expect(reservation.day_positions[day.id]).toBe(1.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shared trip — place photos in shared links (issue #1100)', () => {
|
||||
const PLACE_ID = 'ChIJsharedPhoto1100';
|
||||
const PROXY_URL = `/api/maps/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`;
|
||||
const photoBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
|
||||
let cachedFilePath: string;
|
||||
|
||||
afterAll(() => { try { if (cachedFilePath) fs.unlinkSync(cachedFilePath); } catch { /* ignore */ } });
|
||||
|
||||
async function setupSharedPlaceWithPhoto() {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const place = createPlace(testDb, trip.id, { name: 'Photo Place' });
|
||||
testDb.prepare('UPDATE places SET image_url = ?, google_place_id = ? WHERE id = ?').run(PROXY_URL, PLACE_ID, place.id);
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
return { token, place };
|
||||
}
|
||||
|
||||
it('SHARE-016 — shared payload rewrites place image_url to the public token-scoped proxy', async () => {
|
||||
const { token } = await setupSharedPlaceWithPhoto();
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
const place = res.body.places.find((p: any) => p.image_url);
|
||||
expect(place.image_url).toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||
expect(place.image_url.startsWith('/api/maps/')).toBe(false);
|
||||
});
|
||||
|
||||
it('SHARE-017 — shared payload rewrites assignment place image_url too', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const day = createDay(testDb, trip.id, { date: '2025-10-01' });
|
||||
const place = createPlace(testDb, trip.id, { name: 'Assigned Photo Place' });
|
||||
testDb.prepare('UPDATE places SET image_url = ? WHERE id = ?').run(PROXY_URL, place.id);
|
||||
createDayAssignment(testDb, day.id, place.id, {});
|
||||
|
||||
const { body: { token } } = await request(app)
|
||||
.post(`/api/trips/${trip.id}/share-link`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({});
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.assignments[day.id][0].place.image_url)
|
||||
.toBe(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||
});
|
||||
|
||||
it('SHARE-018 — public proxy streams cached bytes for a valid token + place (no cookie)', async () => {
|
||||
const { token } = await setupSharedPlaceWithPhoto();
|
||||
const cached = await placePhotoCache.put(PLACE_ID, photoBytes, null);
|
||||
cachedFilePath = cached.filePath;
|
||||
|
||||
const res = await request(app).get(`/api/shared/${token}/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('image/jpeg');
|
||||
expect(Buffer.from(res.body)).toEqual(photoBytes);
|
||||
});
|
||||
|
||||
it('SHARE-019 — public proxy 404s for a placeId not in the shared trip', async () => {
|
||||
const { token } = await setupSharedPlaceWithPhoto();
|
||||
const res = await request(app).get(`/api/shared/${token}/place-photo/ChIJnotInTrip/bytes`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||
});
|
||||
|
||||
it('SHARE-020 — public proxy 404s for an invalid token', async () => {
|
||||
await setupSharedPlaceWithPhoto();
|
||||
const res = await request(app).get(`/api/shared/bad-token/place-photo/${encodeURIComponent(PLACE_ID)}/bytes`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Photo not cached' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -33,9 +34,14 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
@@ -44,7 +50,8 @@ import { authCookie } from '../helpers/auth';
|
||||
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
|
||||
import type { SystemNotice } from '../../src/systemNotices/types';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
// Test notice injected into the registry for notice-specific tests
|
||||
const TEST_NOTICE: SystemNotice = {
|
||||
@@ -59,16 +66,19 @@ const TEST_NOTICE: SystemNotice = {
|
||||
priority: 0,
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -30,30 +31,37 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -30,32 +31,39 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register
|
||||
@@ -43,27 +44,39 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/services/permissions';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
invalidatePermissionsCache();
|
||||
});
|
||||
afterAll(() => { testDb.close(); });
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Create trip (TRIP-001, TRIP-002, TRIP-003)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -35,7 +36,12 @@ vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
SESSION_DURATION: '24h',
|
||||
SESSION_DURATION_MS: 86400000,
|
||||
SESSION_DURATION_SECONDS: 86400,
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
||||
|
||||
// Prevent real HTTP calls (holiday API etc.)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
@@ -56,28 +62,30 @@ vi.mock('../../src/services/vacayService', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
resetRateLimits(nestApp);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* Weather integration tests.
|
||||
* Covers WEATHER-001 to WEATHER-007.
|
||||
*
|
||||
* External API calls (Open-Meteo) are mocked via vi.mock.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
|
||||
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: (placeId: number) => {
|
||||
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
|
||||
if (!place) return null;
|
||||
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
|
||||
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
|
||||
},
|
||||
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-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
|
||||
// Prevent real HTTP calls to Open-Meteo
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
current: { temperature_2m: 22, weathercode: 1, windspeed_10m: 10, relativehumidity_2m: 60, precipitation: 0 },
|
||||
daily: {
|
||||
time: ['2025-06-01'],
|
||||
temperature_2m_max: [25],
|
||||
temperature_2m_min: [18],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [15],
|
||||
sunrise: ['2025-06-01T06:00'],
|
||||
sunset: ['2025-06-01T21:00'],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createApp } from '../../src/app';
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { loginAttempts, mfaAttempts } from '../../src/routes/auth';
|
||||
|
||||
const app: Application = createApp();
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
loginAttempts.clear();
|
||||
mfaAttempts.clear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testDb.close();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('Weather validation', () => {
|
||||
it('WEATHER-001 — GET /weather without lat/lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without lng returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-005 — GET /weather/detailed without date returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather/detailed?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('WEATHER-001 — GET /weather without auth returns 401', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Weather with mocked API', () => {
|
||||
it('WEATHER-001 — GET /weather with lat/lng returns weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('main');
|
||||
});
|
||||
|
||||
it('WEATHER-002 — GET /weather?date=future returns forecast data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 5);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=48.8566&lng=2.3522&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body).toHaveProperty('type');
|
||||
});
|
||||
|
||||
it('WEATHER-006 — GET /weather accepts lang parameter', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/weather?lat=48.8566&lng=2.3522&lang=en')
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
});
|
||||
|
||||
it('WEATHER-007 — GET /weather returns 500 on non-ok API response (ApiError path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
// Use unique coords to avoid cache from previous tests
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Service unavailable' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 3);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=55.0&lng=25.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-008 — GET /weather returns 500 on network error (generic error path)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 4);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather?lat=56.0&lng=26.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-009 — GET /weather/detailed returns detailed weather data', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 2);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
// Override mock with full detailed forecast response
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
daily: {
|
||||
time: [dateStr],
|
||||
temperature_2m_max: [24],
|
||||
temperature_2m_min: [16],
|
||||
weathercode: [1],
|
||||
precipitation_sum: [0],
|
||||
windspeed_10m_max: [12],
|
||||
sunrise: [`${dateStr}T06:00`],
|
||||
sunset: [`${dateStr}T21:00`],
|
||||
precipitation_probability_max: [10],
|
||||
},
|
||||
hourly: {
|
||||
time: [`${dateStr}T12:00`],
|
||||
temperature_2m: [20],
|
||||
precipitation_probability: [5],
|
||||
precipitation: [0],
|
||||
weathercode: [1],
|
||||
windspeed_10m: [10],
|
||||
relativehumidity_2m: [55],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=50.0&lng=10.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('temp');
|
||||
expect(res.body.type).toBe('forecast');
|
||||
});
|
||||
|
||||
it('WEATHER-010 — GET /weather/detailed returns error status on ApiError', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
json: () => Promise.resolve({ error: true, reason: 'Bad Gateway' }),
|
||||
});
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 6);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=57.0&lng=27.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(502);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
|
||||
it('WEATHER-011 — GET /weather/detailed returns 500 on network error', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
vi.mocked(global.fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
||||
const futureDate = new Date();
|
||||
futureDate.setDate(futureDate.getDate() + 7);
|
||||
const dateStr = futureDate.toISOString().slice(0, 10);
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/weather/detailed?lat=58.0&lng=28.0&date=${dateStr}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toHaveProperty('error');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Unit test for the Atlas region-code reconciliation migration (#1119).
|
||||
*
|
||||
* After Atlas swapped Natural Earth for geoBoundaries, manually-marked regions
|
||||
* (`visited_regions`) held the old Natural Earth ISO-3166-2 codes. The final migration
|
||||
* reconciles each row against the shipped admin-1 bundle: valid codes are kept, codes
|
||||
* whose region NAME still matches are re-coded, renamed-merge cases use a curated
|
||||
* crosswalk, and anything else is left untouched. We exercise the real migration by
|
||||
* running all migrations, seeding rows, rewinding schema_version by one, and re-running
|
||||
* so only the last (reconciliation) migration fires.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { createUser } from '../../helpers/factories';
|
||||
|
||||
function freshDb() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
createTables(db);
|
||||
runMigrations(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function mark(db: Database.Database, userId: number, code: string, name: string, country = 'NO') {
|
||||
db.prepare(
|
||||
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
|
||||
).run(userId, code, name, country);
|
||||
}
|
||||
|
||||
// The visited_regions reconciliation (#1119) is pinned at schema version 135.
|
||||
// Migrations added afterwards are appended AFTER it (append-only), so it is no
|
||||
// longer the last migration. Rewind to just before the reconciliation and
|
||||
// re-run: the later migrations are idempotent, so only the reconciliation has
|
||||
// any effect on the seeded rows here.
|
||||
const RECONCILIATION_VERSION = 135;
|
||||
function rerunLastMigration(db: Database.Database) {
|
||||
db.prepare('UPDATE schema_version SET version = ?').run(RECONCILIATION_VERSION - 1);
|
||||
runMigrations(db);
|
||||
}
|
||||
|
||||
describe('Atlas region-code reconciliation migration', () => {
|
||||
it('CROSSWALK-001: remaps a renamed-merge county via the curated crosswalk', () => {
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
mark(db, user.id, 'NO-05', 'Oppland'); // merged into Innlandet, name changed
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||
expect(rows).toEqual([{ region_code: 'NO-34', region_name: 'Innlandet' }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('CROSSWALK-002: merges two old counties that map to the same new region (no UNIQUE clash)', () => {
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
mark(db, user.id, 'NO-04', 'Hedmark'); // → Innlandet
|
||||
mark(db, user.id, 'NO-05', 'Oppland'); // → Innlandet
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const rows = db.prepare('SELECT region_code FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||
expect(rows).toEqual([{ region_code: 'NO-34' }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('CROSSWALK-003: leaves a still-valid code untouched', () => {
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
mark(db, user.id, 'NO-03', 'Oslo'); // present in the new bundle
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('CROSSWALK-004: re-codes a stale code whose region NAME still matches the bundle', () => {
|
||||
// Not in any crosswalk: a bogus code but a name ("Oslo") that the bundle still carries
|
||||
// for NO → reconciled to the bundle's code for that name (NO-03) by the name-match path.
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
mark(db, user.id, 'NO-99', 'Oslo');
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||
expect(rows).toEqual([{ region_code: 'NO-03', region_name: 'Oslo' }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('CROSSWALK-005: leaves an unresolvable row as-is (no code, no name, no crosswalk match)', () => {
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
mark(db, user.id, 'ZZ-99', 'Nowhere', 'ZZ');
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const rows = db.prepare('SELECT region_code, region_name FROM visited_regions WHERE user_id = ?').all(user.id);
|
||||
expect(rows).toEqual([{ region_code: 'ZZ-99', region_name: 'Nowhere' }]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('CROSSWALK-006: does not touch bucket_list or visited_countries (no region identifier there)', () => {
|
||||
const db = freshDb();
|
||||
const { user } = createUser(db);
|
||||
db.prepare('INSERT INTO bucket_list (user_id, name, country_code) VALUES (?, ?, ?)').run(user.id, 'Oppland', 'NO');
|
||||
db.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'NO');
|
||||
mark(db, user.id, 'NO-05', 'Oppland'); // ensure the migration actually runs its body
|
||||
|
||||
rerunLastMigration(db);
|
||||
|
||||
const bucket = db.prepare('SELECT name, country_code FROM bucket_list WHERE user_id = ?').all(user.id);
|
||||
expect(bucket).toEqual([{ name: 'Oppland', country_code: 'NO' }]); // free-text name untouched
|
||||
const countries = db.prepare('SELECT country_code FROM visited_countries WHERE user_id = ?').all(user.id);
|
||||
expect(countries).toEqual([{ country_code: 'NO' }]);
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Migration hygiene guardrails.
|
||||
*
|
||||
* These tests scan the migration source statically and fail when a NEW
|
||||
* destructive operation (DROP TABLE / DROP COLUMN / TRUNCATE / DELETE FROM /
|
||||
* ALTER ... DROP) is introduced, or when an empty/silent `catch` block creeps
|
||||
* back into the migration runner.
|
||||
*
|
||||
* Migrations 1..N are append-only and immutable once shipped (the live schema
|
||||
* has already applied them; rewriting an applied migration is a breaking
|
||||
* change). The destructive statements that already exist were each reviewed
|
||||
* and are legitimate — almost all are the standard SQLite "table rebuild"
|
||||
* pattern (create *_new, copy rows, DROP old, RENAME), plus a handful of
|
||||
* deliberate, data-preserving cleanups. They are recorded in
|
||||
* ALLOWED_DESTRUCTIVE below with the reason. Anything not on that list is
|
||||
* treated as a regression.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { createTestDb } from '../../helpers/test-db';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_PATH = resolve(here, '../../../src/db/migrations.ts');
|
||||
const migrationsSource = readFileSync(MIGRATIONS_PATH, 'utf8');
|
||||
|
||||
/**
|
||||
* Strip line and block comments so commented-out SQL (or prose mentioning
|
||||
* "DROP TABLE") is never flagged. String/template contents are preserved —
|
||||
* that is exactly where the real SQL lives.
|
||||
*/
|
||||
function stripComments(src: string): string {
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
|
||||
const scannableSource = stripComments(migrationsSource);
|
||||
|
||||
interface DestructiveHit {
|
||||
/** Normalised signature used as the allowlist key, e.g. "DROP TABLE budget_items". */
|
||||
signature: string;
|
||||
/** The raw matched fragment, kept for diagnostics. */
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects destructive DDL/DML. For each match we build a normalised signature
|
||||
* of "<OPERATION> <TARGET>" so cosmetic whitespace/quoting changes don't churn
|
||||
* the allowlist, while a genuinely new target (or operation) shows up as a new
|
||||
* signature.
|
||||
*/
|
||||
function findDestructiveStatements(src: string): DestructiveHit[] {
|
||||
const hits: DestructiveHit[] = [];
|
||||
const norm = (s: string) => s.replace(/[`"'\[\]]/g, '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
// DROP TABLE [IF EXISTS] <name>
|
||||
for (const m of src.matchAll(/DROP\s+TABLE\s+(IF\s+EXISTS\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
||||
hits.push({ signature: `DROP TABLE ${m[2]}`, fragment: norm(m[0]) });
|
||||
}
|
||||
// ALTER TABLE <t> DROP COLUMN <c> (and bare ALTER ... DROP <c>)
|
||||
for (const m of src.matchAll(/ALTER\s+TABLE\s+[`"'\[]?([A-Za-z_][\w]*)[`"'\]]?\s+DROP\s+(COLUMN\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
||||
hits.push({ signature: `ALTER TABLE ${m[1]} DROP COLUMN ${m[3]}`, fragment: norm(m[0]) });
|
||||
}
|
||||
// TRUNCATE <t> (not valid SQLite, but guard anyway)
|
||||
for (const m of src.matchAll(/TRUNCATE\s+(TABLE\s+)?[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
||||
hits.push({ signature: `TRUNCATE ${m[2]}`, fragment: norm(m[0]) });
|
||||
}
|
||||
// DELETE FROM <t>
|
||||
for (const m of src.matchAll(/DELETE\s+FROM\s+[`"'\[]?([A-Za-z_][\w]*)/gi)) {
|
||||
hits.push({ signature: `DELETE FROM ${m[1]}`, fragment: norm(m[0]) });
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist of destructive statements already present and reviewed as
|
||||
* legitimate. Keyed by normalised signature. NEVER add to this without a
|
||||
* code-review-level justification — that is the whole point of the guard.
|
||||
*
|
||||
* Rebuild = standard SQLite 12-step ALTER emulation: CREATE <t>_new,
|
||||
* INSERT ... SELECT to copy rows, DROP old <t>, ALTER ... RENAME <t>_new TO <t>.
|
||||
* Rows are preserved across the rebuild.
|
||||
*/
|
||||
const ALLOWED_DESTRUCTIVE: Record<string, string> = {
|
||||
// ── table rebuilds (data preserved) ──────────────────────────────────────
|
||||
'DROP TABLE budget_items':
|
||||
'Migration 12: rebuild to drop a stale NOT NULL DEFAULT on persons/days. Rows copied first.',
|
||||
'DROP TABLE oauth_clients':
|
||||
'Make oauth_clients.user_id nullable for anonymous DCR clients. Rebuild, rows copied.',
|
||||
'DROP TABLE idempotency_keys':
|
||||
'Widen PK to (key,user_id,method,path). Rebuild, rows copied (old PK is a subset).',
|
||||
'DROP TABLE day_accommodations':
|
||||
'Make place_id nullable + ON DELETE SET NULL. Rebuild, rows copied.',
|
||||
'DROP TABLE schema_version':
|
||||
'Add surrogate id PK to schema_version. Rebuild, version row copied.',
|
||||
|
||||
// ── photo/journey table rebuilds (data preserved) ────────────────────────
|
||||
'DROP TABLE trip_photos':
|
||||
'trip_photos normalisation + later photo_id FK refactor. Rebuilds, rows copied.',
|
||||
'DROP TABLE trip_album_links':
|
||||
'Normalise trip_album_links to provider+album_id schema. Rebuild, rows copied.',
|
||||
'DROP TABLE journey_photos':
|
||||
'Journey photo provider support + photo_id FK refactor. Rebuilds, rows copied.',
|
||||
'DROP TABLE journey_photos_old':
|
||||
'Migration 121 gallery refactor: drops the temporary *_old backup after backfill.',
|
||||
'DROP TABLE journey_location_trail':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
'DROP TABLE journey_entries':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
'DROP TABLE journey_checkins':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
'DROP TABLE journey_members':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
'DROP TABLE journey_trips':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
'DROP TABLE journeys':
|
||||
'Migration 87 journey rebuild: old data SELECTed into memory and re-inserted into new schema.',
|
||||
|
||||
// ── template/cache scaffolding drops (no user content lost) ──────────────
|
||||
'DROP TABLE packing_template_items':
|
||||
'IF EXISTS drop to recreate the template-items table with a category_id FK. Template scaffolding.',
|
||||
'DROP TABLE notification_preferences':
|
||||
'IF EXISTS drop AFTER migration 71 copied the data into notification_channel_preferences.',
|
||||
|
||||
// ── guarded column drop ──────────────────────────────────────────────────
|
||||
'ALTER TABLE photo_providers DROP COLUMN config':
|
||||
'Drop generated-only config column; guarded by a PRAGMA table_info check that it exists.',
|
||||
|
||||
// ── targeted, bounded DELETEs ────────────────────────────────────────────
|
||||
'DELETE FROM oauth_tokens':
|
||||
'SEC-H6: DELETE ... WHERE audience IS NULL — purge pre-audience-binding tokens that the MCP server now rejects.',
|
||||
'DELETE FROM journey_entries':
|
||||
"Migration 121: DELETE ... WHERE title IN ('Gallery','[Trip Photos]') — remove synthetic wrapper entries replaced by the gallery model.",
|
||||
'DELETE FROM place_regions':
|
||||
'Atlas enclave fix: DELETE ... WHERE place_id IN (places inside specific enclave boxes) — invalidate stale region cache; re-resolved on next request.',
|
||||
'DELETE FROM visited_regions':
|
||||
'Atlas geoBoundaries swap (#1119): DELETE ... WHERE id = ? — after UPDATE OR IGNORE re-codes a manually-marked region to its current code, drop only the single leftover row whose UNIQUE(user_id, region_code) collision caused the update to be skipped (a duplicate of a region the user already has).',
|
||||
};
|
||||
|
||||
describe('migration hygiene — destructive operation guard', () => {
|
||||
it('introduces no destructive migration statement outside the reviewed allowlist', () => {
|
||||
const hits = findDestructiveStatements(scannableSource);
|
||||
const offenders = hits.filter((h) => !(h.signature in ALLOWED_DESTRUCTIVE));
|
||||
|
||||
if (offenders.length > 0) {
|
||||
const detail = offenders
|
||||
.map((o) => ` • ${o.signature} (matched: "${o.fragment}")`)
|
||||
.join('\n');
|
||||
throw new Error(
|
||||
`Found ${offenders.length} destructive migration statement(s) that are not on the ` +
|
||||
`reviewed allowlist in tests/unit/db/migration-hygiene.test.ts.\n` +
|
||||
`Migrations are append-only and destructive DDL/DML risks data loss on upgrade.\n` +
|
||||
`If the statement is genuinely safe (e.g. a SQLite table rebuild that copies rows ` +
|
||||
`first, or a tightly-bounded cache/cleanup DELETE), add its signature to ` +
|
||||
`ALLOWED_DESTRUCTIVE with a justification.\n\nOffending statement(s):\n${detail}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it('every allowlist entry still corresponds to a real statement (no dead allowlist rows)', () => {
|
||||
const present = new Set(findDestructiveStatements(scannableSource).map((h) => h.signature));
|
||||
const dead = Object.keys(ALLOWED_DESTRUCTIVE).filter((sig) => !present.has(sig));
|
||||
expect(dead, `Allowlist entries no longer found in migrations.ts: ${dead.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration hygiene — no silently swallowed errors', () => {
|
||||
it('contains no empty catch block (catch must at least log)', () => {
|
||||
// Matches `catch {}` and `catch (e) {}` where the body is only whitespace.
|
||||
const emptyCatch = scannableSource.match(/catch\s*(\([^)]*\))?\s*\{\s*\}/g) ?? [];
|
||||
expect(
|
||||
emptyCatch,
|
||||
`migrations.ts must not swallow errors silently. Give each catch a log line ` +
|
||||
`(e.g. console.warn('[migrations] ...', err)). Found: ${emptyCatch.length}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('migration hygiene — full chain smoke', () => {
|
||||
it('migrates a fresh in-memory database from zero to the latest version', () => {
|
||||
// createTestDb() runs createTables() + the entire runMigrations() chain.
|
||||
// This proves the logging edits in the previously-empty catch blocks do
|
||||
// not change control flow / break the migration runner.
|
||||
const db = createTestDb();
|
||||
try {
|
||||
const row = db.prepare('SELECT version FROM schema_version').get() as { version: number };
|
||||
expect(row.version).toBeGreaterThan(0);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Idempotency key TTL cleanup (H6).
|
||||
*
|
||||
* The TREK client replays queued mutations with their X-Idempotency-Key on
|
||||
* reconnect, so the server must keep keys long enough to cover a realistic
|
||||
* offline window — otherwise a key GC'd before the device returns lets the
|
||||
* replay create a duplicate. The TTL was raised from 24h to 30d (overridable).
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { db } from '../../src/db/database';
|
||||
import { purgeExpiredIdempotencyKeys } from '../../src/scheduler';
|
||||
|
||||
const DAY = 24 * 60 * 60;
|
||||
const NOW = 2_000_000_000_000; // fixed ms so the test is deterministic
|
||||
const NOW_SEC = Math.floor(NOW / 1000);
|
||||
|
||||
function insertKey(key: string, ageSeconds: number): void {
|
||||
db.prepare(
|
||||
`INSERT INTO idempotency_keys (key, user_id, method, path, status_code, response_body, created_at)
|
||||
VALUES (?, 1, 'POST', '/x', 200, '{}', ?)`,
|
||||
).run(key, NOW_SEC - ageSeconds);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db.pragma('foreign_keys = OFF'); // fixtures reference a user we don't seed here
|
||||
db.prepare('DELETE FROM idempotency_keys').run();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.prepare('DELETE FROM idempotency_keys').run();
|
||||
db.pragma('foreign_keys = ON');
|
||||
delete process.env.IDEMPOTENCY_TTL_SECONDS;
|
||||
});
|
||||
|
||||
describe('purgeExpiredIdempotencyKeys', () => {
|
||||
it('removes keys older than the 30-day default, keeps recent ones', () => {
|
||||
insertKey('old', 31 * DAY);
|
||||
insertKey('fresh', 5 * DAY);
|
||||
|
||||
const removed = purgeExpiredIdempotencyKeys(NOW, undefined, db);
|
||||
|
||||
expect(removed).toBe(1);
|
||||
const keys = db.prepare('SELECT key FROM idempotency_keys').all().map((r: { key: string }) => r.key);
|
||||
expect(keys).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('keeps a 25-day-old key that the old 24h TTL would have dropped', () => {
|
||||
insertKey('offline-trip', 25 * DAY);
|
||||
expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(0);
|
||||
expect(db.prepare('SELECT COUNT(*) c FROM idempotency_keys').get()).toMatchObject({ c: 1 });
|
||||
});
|
||||
|
||||
it('respects the IDEMPOTENCY_TTL_SECONDS override', () => {
|
||||
process.env.IDEMPOTENCY_TTL_SECONDS = String(DAY);
|
||||
insertKey('twoDays', 2 * DAY);
|
||||
expect(purgeExpiredIdempotencyKeys(NOW, undefined, db)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -88,7 +88,7 @@ describe('Tool: create_budget_item', () => {
|
||||
arguments: { tripId: trip.id, name: 'Misc', total_price: 10 },
|
||||
});
|
||||
const data = parseToolResult(result) as any;
|
||||
expect(data.item.category).toBe('Other');
|
||||
expect(data.item.category).toBe('other');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -71,8 +71,10 @@ beforeEach(() => {
|
||||
isAddonEnabledMock.mockReturnValue(true);
|
||||
|
||||
// Default mock: returns a trip-summary-shaped value from the real in-memory DB
|
||||
// so that the trip title / existence match what tests insert, but budget/packing
|
||||
// are arrays (as prompts.ts expects), not the object shape getTripSummary now returns.
|
||||
// so the trip title / existence match what tests insert. `budget` mirrors the
|
||||
// real getTripSummary object shape ({ items, total, ... }) that prompts.ts reads
|
||||
// via budget.items/budget.total; packing stays an array (the packing prompt
|
||||
// tolerates it).
|
||||
mockGetTripSummary.mockImplementation((tripId: any) => {
|
||||
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
|
||||
if (!trip) return null;
|
||||
@@ -87,8 +89,13 @@ beforeEach(() => {
|
||||
trip,
|
||||
days: [],
|
||||
members,
|
||||
budget: budgetRows, // array shape expected by prompts.ts
|
||||
packing: packingRows, // array shape expected by prompts.ts
|
||||
budget: {
|
||||
items: budgetRows,
|
||||
item_count: budgetRows.length,
|
||||
total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0),
|
||||
currency: trip.currency,
|
||||
},
|
||||
packing: packingRows, // array shape; packing prompt tolerates it
|
||||
reservations: [],
|
||||
collabNotes: [],
|
||||
};
|
||||
|
||||
@@ -134,6 +134,23 @@ describe('authenticate', () => {
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
|
||||
it('AUTH-MW-007: rejects a purpose-scoped mfa_login token even when the user is valid', () => {
|
||||
// The token issued after the password check but before TOTP is signed with
|
||||
// the same secret. It must never authenticate a normal request, otherwise
|
||||
// password alone grants full access and MFA is bypassed.
|
||||
const mockUser = { id: 1, username: 'alice', email: 'alice@example.com', role: 'user', password_version: 0 };
|
||||
vi.mocked(db.prepare).mockReturnValue({ get: vi.fn(() => mockUser), all: vi.fn() } as any);
|
||||
|
||||
const mfaToken = jwt.sign({ id: 1, purpose: 'mfa_login', pv: 0 }, 'test-secret', { algorithm: 'HS256' });
|
||||
const next = vi.fn() as unknown as NextFunction;
|
||||
const { res, status } = makeRes();
|
||||
|
||||
authenticate(makeReq({ headers: { authorization: `Bearer ${mfaToken}` } }), res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(status).toHaveBeenCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
// ── adminOnly ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { AccommodationsController } from '../../../src/nest/reservations/accommodations.controller';
|
||||
import type { AccommodationsService } from '../../../src/nest/reservations/accommodations.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { user_id: 1 };
|
||||
const refs = { place_id: 2, start_day_id: 10, end_day_id: 11 };
|
||||
|
||||
function makeService(overrides: Partial<AccommodationsService> = {}): AccommodationsService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
validateRefs: vi.fn().mockReturnValue([]),
|
||||
...overrides,
|
||||
} as unknown as AccommodationsService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
describe('AccommodationsController (parity with the legacy accommodations sub-router)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new AccommodationsController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / lists (no permission gate)', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).list(user, '5')).toEqual({ accommodations: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without day_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 when refs are missing', () => {
|
||||
expect(thrown(() => new AccommodationsController(makeService()).create(user, '5', { place_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'place_id, start_day_id, and end_day_id are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 with the first validateRefs error message', () => {
|
||||
const svc = makeService({ validateRefs: vi.fn().mockReturnValue([{ field: 'place_id', message: 'Place not found' }]) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).create(user, '5', refs))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
});
|
||||
|
||||
it('creates and emits accommodation:created + reservation:created', () => {
|
||||
const create = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ create, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).create(user, '5', refs, 'sock')).toEqual({ accommodation: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:created', { accommodation: { id: 9 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:created', {}, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the accommodation is missing', () => {
|
||||
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).update(user, '5', '9', refs))).toEqual({ status: 404, body: { error: 'Accommodation not found' } });
|
||||
});
|
||||
|
||||
it('updates and broadcasts', () => {
|
||||
const get = vi.fn().mockReturnValue({ id: 9 });
|
||||
const update = vi.fn().mockReturnValue({ id: 9, notes: 'x' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ get, update, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).update(user, '5', '9', refs, 'sock')).toEqual({ accommodation: { id: 9, notes: 'x' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:updated', { accommodation: { id: 9, notes: 'x' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when missing', () => {
|
||||
const svc = makeService({ get: vi.fn().mockReturnValue(undefined) } as Partial<AccommodationsService>);
|
||||
expect(thrown(() => new AccommodationsController(svc).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Accommodation not found' } });
|
||||
});
|
||||
|
||||
it('emits the linked reservation/budget cascade then accommodation:deleted', () => {
|
||||
const get = vi.fn().mockReturnValue({ id: 9 });
|
||||
const remove = vi.fn().mockReturnValue({ linkedReservationId: 4, deletedBudgetItemId: 7 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ get, remove, broadcast } as Partial<AccommodationsService>);
|
||||
expect(new AccommodationsController(svc).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:deleted', { reservationId: 4 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:deleted', { itemId: 7 }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'accommodation:deleted', { accommodationId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AddonsController } from '../../../src/nest/addons/addons.controller';
|
||||
import type { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function makeService(overrides: Partial<AddonsService> = {}): AddonsService {
|
||||
return {
|
||||
list: vi.fn().mockReturnValue({ collabFeatures: {}, bagTracking: false, addons: [] }),
|
||||
...overrides,
|
||||
} as unknown as AddonsService;
|
||||
}
|
||||
|
||||
describe('AddonsController (parity with the legacy GET /api/addons route)', () => {
|
||||
it('GET / delegates straight to the service and returns its feed', () => {
|
||||
const feed = {
|
||||
collabFeatures: { comments: true },
|
||||
bagTracking: true,
|
||||
addons: [{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true }],
|
||||
};
|
||||
const list = vi.fn().mockReturnValue(feed);
|
||||
const svc = makeService({ list } as Partial<AddonsService>);
|
||||
|
||||
expect(new AddonsController(svc).list()).toBe(feed);
|
||||
expect(list).toHaveBeenCalledTimes(1);
|
||||
expect(list).toHaveBeenCalledWith();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Three distinct prepare(...).all() reads (addons, photo_providers, photo_provider_fields).
|
||||
// A single shared statement is reused, so .all() is fed result sets in call order.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { getBagTracking, getCollabFeatures } = vi.hoisted(() => ({
|
||||
getBagTracking: vi.fn(() => ({ enabled: false })),
|
||||
getCollabFeatures: vi.fn(() => ({})),
|
||||
}));
|
||||
vi.mock('../../../src/services/adminService', () => ({ getBagTracking, getCollabFeatures }));
|
||||
|
||||
const { getPhotoProviderConfig } = vi.hoisted(() => ({ getPhotoProviderConfig: vi.fn(() => ({})) }));
|
||||
vi.mock('../../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig }));
|
||||
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
|
||||
function svc() {
|
||||
return new AddonsService();
|
||||
}
|
||||
|
||||
// Feed the three reads in order: addons, providers, fields.
|
||||
function feedReads(addons: unknown[], providers: unknown[], fields: unknown[]) {
|
||||
dbMock._stmt.all
|
||||
.mockReturnValueOnce(addons)
|
||||
.mockReturnValueOnce(providers)
|
||||
.mockReturnValueOnce(fields);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dbMock._stmt.all.mockReturnValue([]);
|
||||
getCollabFeatures.mockReturnValue({});
|
||||
getBagTracking.mockReturnValue({ enabled: false });
|
||||
getPhotoProviderConfig.mockReturnValue({});
|
||||
});
|
||||
|
||||
describe('AddonsService.list', () => {
|
||||
it('returns the collab features and the bag-tracking flag from the admin service', () => {
|
||||
getCollabFeatures.mockReturnValue({ comments: true });
|
||||
getBagTracking.mockReturnValue({ enabled: true });
|
||||
feedReads([], [], []);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.collabFeatures).toEqual({ comments: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
|
||||
it('coerces the addon enabled column to a boolean (both 1 and 0)', () => {
|
||||
feedReads(
|
||||
[
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: 0 },
|
||||
],
|
||||
[],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: true },
|
||||
{ id: 'vacay', name: 'Vacay', type: 'page', icon: 'sun', enabled: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps a photo provider with no fields to an empty fields array (the || [] fallback)', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
getPhotoProviderConfig.mockReturnValue({ baseUrl: 'http://x' });
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons).toEqual([
|
||||
{
|
||||
id: 'immich',
|
||||
name: 'Immich',
|
||||
type: 'photo_provider',
|
||||
icon: 'image',
|
||||
enabled: true,
|
||||
config: { baseUrl: 'http://x' },
|
||||
fields: [],
|
||||
},
|
||||
]);
|
||||
expect(getPhotoProviderConfig).toHaveBeenCalledWith('immich');
|
||||
});
|
||||
|
||||
it('coerces a disabled photo provider enabled flag to false', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'synology', name: 'Synology', icon: 'image', enabled: 0, sort_order: 1 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { enabled: boolean }).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('groups multiple fields under their provider and keeps insertion order', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: 1,
|
||||
secret: 0,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
// Second field for the SAME provider exercises the `get(...) || []` truthy branch.
|
||||
{
|
||||
provider_id: 'immich',
|
||||
field_key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: null,
|
||||
hint: null,
|
||||
required: 0,
|
||||
secret: 1,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const provider = res.addons[0] as { fields: Array<Record<string, unknown>> };
|
||||
expect(provider.fields).toEqual([
|
||||
{
|
||||
key: 'url',
|
||||
label: 'URL',
|
||||
input_type: 'text',
|
||||
placeholder: 'https://',
|
||||
hint: 'Base URL',
|
||||
required: true,
|
||||
secret: false,
|
||||
settings_key: 'immich_url',
|
||||
payload_key: 'url',
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
key: 'token',
|
||||
label: 'Token',
|
||||
input_type: 'password',
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
required: false,
|
||||
secret: true,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
sort_order: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back placeholder→"", hint→null, settings/payload keys→null when columns are missing/empty', () => {
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'p', name: 'P', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'p',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
// placeholder/hint/settings_key/payload_key omitted entirely (undefined)
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
const field = (res.addons[0] as { fields: Array<Record<string, unknown>> }).fields[0];
|
||||
expect(field).toMatchObject({
|
||||
placeholder: '',
|
||||
hint: null,
|
||||
settings_key: null,
|
||||
payload_key: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps fields belonging to other providers out of a provider with none of its own', () => {
|
||||
// A field exists, but for a DIFFERENT provider than the one returned — exercises
|
||||
// the `fieldsByProvider.get(p.id) || []` fallback while the map is non-empty.
|
||||
feedReads(
|
||||
[],
|
||||
[{ id: 'has-none', name: 'X', icon: 'i', enabled: 1, sort_order: 0 }],
|
||||
[
|
||||
{
|
||||
provider_id: 'other',
|
||||
field_key: 'k',
|
||||
label: 'L',
|
||||
input_type: 'text',
|
||||
required: 0,
|
||||
secret: 0,
|
||||
sort_order: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect((res.addons[0] as { fields: unknown[] }).fields).toEqual([]);
|
||||
});
|
||||
|
||||
it('concatenates regular addons before the photo providers', () => {
|
||||
feedReads(
|
||||
[{ id: 'atlas', name: 'Atlas', type: 'page', icon: 'globe', enabled: 1 }],
|
||||
[{ id: 'immich', name: 'Immich', icon: 'image', enabled: 1, sort_order: 0 }],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.addons.map((a) => (a as { id: string }).id)).toEqual(['atlas', 'immich']);
|
||||
expect((res.addons[1] as { type: string }).type).toBe('photo_provider');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException, NotFoundException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() }));
|
||||
vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import { AdminController } from '../../../src/nest/admin/admin.controller';
|
||||
import type { AdminService } from '../../../src/nest/admin/admin.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import { send as sendNotification } from '../../../src/services/notificationService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'admin@example.test' } as User;
|
||||
const req = { headers: {} } as Request;
|
||||
|
||||
function svc(o: Partial<AdminService> = {}): AdminService {
|
||||
return { invalidateMcpSessions: vi.fn(), ...o } as unknown as AdminService;
|
||||
}
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
if (err instanceof NotFoundException) return { status: 404, body: err.getResponse() };
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
describe('AdminController users', () => {
|
||||
it('lists, creates (201 + audit), maps an error', () => {
|
||||
expect(new AdminController(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
|
||||
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||
const c = new AdminController(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
|
||||
expect(c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.user_create' }));
|
||||
});
|
||||
|
||||
it('update + delete audit and map errors', () => {
|
||||
expect(new AdminController(svc({ updateUser: vi.fn().mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }) } as Partial<AdminService>)).updateUser(user, '2', {}, req)).toEqual({ user: { id: 2 } });
|
||||
expect(thrown(() => new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ error: 'Cannot delete self', status: 400 }) } as Partial<AdminService>)).deleteUser(user, '1', req))).toEqual({ status: 400, body: { error: 'Cannot delete self' } });
|
||||
expect(new AdminController(svc({ deleteUser: vi.fn().mockReturnValue({ email: 'a@b.c' }) } as Partial<AdminService>)).deleteUser(user, '2', req)).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController permissions + oidc + misc', () => {
|
||||
it('permissions: 400 without an object, else saves + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).savePermissions(user, {}, req))).toEqual({ status: 400, body: { error: 'permissions object required' } });
|
||||
const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: { x: 1 }, skipped: [] }) } as Partial<AdminService>));
|
||||
expect(c.savePermissions(user, { permissions: { x: 1 } }, req)).toEqual({ success: true, permissions: { x: 1 } });
|
||||
});
|
||||
|
||||
it('permissions: includes skipped when present', () => {
|
||||
const c = new AdminController(svc({ savePermissions: vi.fn().mockReturnValue({ permissions: {}, skipped: ['bad'] }) } as Partial<AdminService>));
|
||||
expect(c.savePermissions(user, { permissions: {} }, req)).toEqual({ success: true, permissions: {}, skipped: ['bad'] });
|
||||
});
|
||||
|
||||
it('oidc update maps error, else audits', () => {
|
||||
expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'bad issuer', status: 400 }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'bad issuer' } });
|
||||
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, { issuer: 'https://idp' }, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('save-demo-baseline maps error, else returns message', () => {
|
||||
expect(thrown(() => new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ error: 'not demo', status: 400 }) } as Partial<AdminService>)).saveDemoBaseline(user, req))).toEqual({ status: 400, body: { error: 'not demo' } });
|
||||
expect(new AdminController(svc({ saveDemoBaseline: vi.fn().mockReturnValue({ message: 'saved' }) } as Partial<AdminService>)).saveDemoBaseline(user, req)).toEqual({ success: true, message: 'saved' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController invites + feature toggles', () => {
|
||||
it('invites: create 201 + audit, delete maps error', () => {
|
||||
const c = new AdminController(svc({ createInvite: vi.fn().mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }) } as Partial<AdminService>));
|
||||
expect(c.createInvite(user, {}, req)).toEqual({ invite: { id: 5 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.invite_create' }));
|
||||
expect(thrown(() => new AdminController(svc({ deleteInvite: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).deleteInvite(user, '5', req))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||
});
|
||||
|
||||
it('places-photos: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesPhotos(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesPhotos(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it('collab-features update invalidates MCP sessions + audits', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateCollabFeatures: vi.fn().mockReturnValue({ chat: true }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateCollabFeatures(user, { chat: true }, req)).toEqual({ chat: true });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController packing templates', () => {
|
||||
it('get 404, create 201, delete audits', () => {
|
||||
expect(thrown(() => new AdminController(svc({ getPackingTemplate: vi.fn().mockReturnValue({ error: 'not found', status: 404 }) } as Partial<AdminService>)).getPackingTemplate('9'))).toEqual({ status: 404, body: { error: 'not found' } });
|
||||
expect(new AdminController(svc({ createPackingTemplate: vi.fn().mockReturnValue({ id: 3, name: 'Beach' }) } as Partial<AdminService>)).createPackingTemplate(user, { name: 'Beach' })).toEqual({ id: 3, name: 'Beach' });
|
||||
expect(new AdminController(svc({ deletePackingTemplate: vi.fn().mockReturnValue({ name: 'Beach' }) } as Partial<AdminService>)).deletePackingTemplate(user, '3', req)).toEqual({ success: true });
|
||||
expect(new AdminController(svc({ createTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).createTemplateItem('3', '4', { name: 'Towel' })).toEqual({ id: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
it('addon update audits + invalidates MCP sessions', () => {
|
||||
const invalidateMcpSessions = vi.fn();
|
||||
const c = new AdminController(svc({ updateAddon: vi.fn().mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} }), invalidateMcpSessions } as Partial<AdminService>));
|
||||
expect(c.updateAddon(user, 'mcp', { enabled: true }, req)).toEqual({ addon: { id: 'mcp', enabled: true } });
|
||||
expect(invalidateMcpSessions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('oauth-sessions revoke audits; rotate-jwt maps error', () => {
|
||||
expect(new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).revokeOAuthSession(user, '3', req)).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({ error: 'locked', status: 409 }) } as Partial<AdminService>)).rotateJwtSecret(user, req))).toEqual({ status: 409, body: { error: 'locked' } });
|
||||
expect(new AdminController(svc({ rotateJwtSecret: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).rotateJwtSecret(user, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('default-user-settings: 400 on a non-object, else sets + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, [], req))).toEqual({ status: 400, body: { error: 'Object body required' } });
|
||||
const setAdminUserDefaults = vi.fn();
|
||||
const c = new AdminController(svc({ setAdminUserDefaults, getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>));
|
||||
expect(c.setDefaultUserSettings(user, { theme: 'dark' }, req)).toEqual({ theme: 'dark' });
|
||||
expect(setAdminUserDefaults).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController error envelope fallbacks', () => {
|
||||
it('ok() defaults to 400 when the error envelope omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ createUser: vi.fn().mockReturnValue({ error: 'boom' }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 400, body: { error: 'boom' } });
|
||||
});
|
||||
|
||||
it('updateOidc defaults to 400 when the service error omits a status', () => {
|
||||
expect(thrown(() => new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({ error: 'nope' }) } as Partial<AdminService>)).updateOidc(user, {}, req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('updateOidc audits issuer_set=false when no issuer is supplied', () => {
|
||||
expect(new AdminController(svc({ updateOidcSettings: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).updateOidc(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.oidc_update', details: { issuer_set: false } }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController read-only getters', () => {
|
||||
it('return service values verbatim', () => {
|
||||
expect(new AdminController(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
|
||||
expect(new AdminController(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
|
||||
expect(new AdminController(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
|
||||
expect(new AdminController(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
|
||||
expect(new AdminController(svc({ getOidcSettings: vi.fn().mockReturnValue({ issuer: 'x' }) } as Partial<AdminService>)).getOidc()).toEqual({ issuer: 'x' });
|
||||
expect(new AdminController(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
|
||||
expect(new AdminController(svc({ getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [] }) } as Partial<AdminService>)).getNotificationPrefs(user)).toEqual({ rows: [] });
|
||||
expect(new AdminController(svc({ listInvites: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listInvites()).toEqual({ invites: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getBagTracking: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).getBagTracking()).toEqual({ enabled: false });
|
||||
expect(new AdminController(svc({ getPlacesPhotos: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesPhotos()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesAutocomplete: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesAutocomplete()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getPlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).getPlacesDetails()).toEqual({ enabled: true });
|
||||
expect(new AdminController(svc({ getCollabFeatures: vi.fn().mockReturnValue({ chat: false }) } as Partial<AdminService>)).getCollabFeatures()).toEqual({ chat: false });
|
||||
expect(new AdminController(svc({ listPackingTemplates: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listPackingTemplates()).toEqual({ templates: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
|
||||
expect(new AdminController(svc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ listOAuthSessions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
|
||||
expect(new AdminController(svc({ getAdminUserDefaults: vi.fn().mockReturnValue({ theme: 'dark' }) } as Partial<AdminService>)).getDefaultUserSettings()).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('setNotificationPrefs persists then returns the refreshed matrix', () => {
|
||||
const setAdminPreferences = vi.fn();
|
||||
const c = new AdminController(svc({ setAdminPreferences, getPreferencesMatrix: vi.fn().mockReturnValue({ rows: [1] }) } as Partial<AdminService>));
|
||||
expect(c.setNotificationPrefs(user, { x: 1 })).toEqual({ rows: [1] });
|
||||
expect(setAdminPreferences).toHaveBeenCalledWith(user.id, { x: 1 });
|
||||
});
|
||||
|
||||
it('githubReleases falls back to default paging when no query is given', async () => {
|
||||
const getGithubReleases = vi.fn().mockResolvedValue([{ tag: 'v1' }]);
|
||||
const c = new AdminController(svc({ getGithubReleases } as Partial<AdminService>));
|
||||
await expect(c.githubReleases()).resolves.toEqual([{ tag: 'v1' }]);
|
||||
expect(getGithubReleases).toHaveBeenCalledWith('10', '1');
|
||||
await c.githubReleases('5', '2');
|
||||
expect(getGithubReleases).toHaveBeenLastCalledWith('5', '2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController feature toggles + audit', () => {
|
||||
it('bag-tracking updates and audits', () => {
|
||||
const c = new AdminController(svc({ updateBagTracking: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>));
|
||||
expect(c.updateBagTracking(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'admin.bag_tracking' }));
|
||||
});
|
||||
|
||||
it('places-autocomplete: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesAutocomplete(user, { enabled: 'yes' }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesAutocomplete: vi.fn().mockReturnValue({ enabled: false }) } as Partial<AdminService>)).updatePlacesAutocomplete(user, { enabled: false }, req)).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it('places-details: 400 on a non-boolean, else updates + audits', () => {
|
||||
expect(thrown(() => new AdminController(svc()).updatePlacesDetails(user, { enabled: 1 }, req))).toEqual({ status: 400, body: { error: 'enabled must be a boolean' } });
|
||||
expect(new AdminController(svc({ updatePlacesDetails: vi.fn().mockReturnValue({ enabled: true }) } as Partial<AdminService>)).updatePlacesDetails(user, { enabled: true }, req)).toEqual({ enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController packing template sub-routes', () => {
|
||||
it('update/delete templates, categories and items map errors + return success', () => {
|
||||
expect(new AdminController(svc({ updatePackingTemplate: vi.fn().mockReturnValue({ id: 3 }) } as Partial<AdminService>)).updatePackingTemplate('3', {})).toEqual({ id: 3 });
|
||||
expect(new AdminController(svc({ createTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).createTemplateCategory('3', { name: 'Tops' })).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ updateTemplateCategory: vi.fn().mockReturnValue({ id: 4 }) } as Partial<AdminService>)).updateTemplateCategory('3', '4', {})).toEqual({ id: 4 });
|
||||
expect(new AdminController(svc({ deleteTemplateCategory: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateCategory('3', '4')).toEqual({ success: true });
|
||||
expect(new AdminController(svc({ updateTemplateItem: vi.fn().mockReturnValue({ id: 7 }) } as Partial<AdminService>)).updateTemplateItem('7', {})).toEqual({ id: 7 });
|
||||
expect(new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteTemplateItem('7')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteTemplateItem: vi.fn().mockReturnValue({ error: 'gone', status: 404 }) } as Partial<AdminService>)).deleteTemplateItem('9'))).toEqual({ status: 404, body: { error: 'gone' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController tokens + sessions', () => {
|
||||
it('mcp token + oauth session deletes return success and map errors', () => {
|
||||
expect(new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AdminService>)).deleteMcpToken('2')).toEqual({ success: true });
|
||||
expect(thrown(() => new AdminController(svc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'no token', status: 404 }) } as Partial<AdminService>)).deleteMcpToken('9'))).toEqual({ status: 404, body: { error: 'no token' } });
|
||||
expect(thrown(() => new AdminController(svc({ revokeOAuthSession: vi.fn().mockReturnValue({ error: 'no session', status: 404 }) } as Partial<AdminService>)).revokeOAuthSession(user, '9', req))).toEqual({ status: 404, body: { error: 'no session' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController default-user-settings error path', () => {
|
||||
it('400 with an Error message when setAdminUserDefaults throws an Error', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw new Error('bad default'); }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'bad default' } });
|
||||
});
|
||||
|
||||
it('400 stringifies a non-Error throw', () => {
|
||||
const c = new AdminController(svc({ setAdminUserDefaults: vi.fn(() => { throw 'plain string'; }) } as Partial<AdminService>));
|
||||
expect(thrown(() => c.setDefaultUserSettings(user, { theme: 'x' }, req))).toEqual({ status: 400, body: { error: 'plain string' } });
|
||||
});
|
||||
|
||||
it('400 when the body is null', () => {
|
||||
expect(thrown(() => new AdminController(svc()).setDefaultUserSettings(user, null, req))).toEqual({ status: 400, body: { error: 'Object body required' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminController dev test-notification', () => {
|
||||
it('404 outside development', async () => {
|
||||
delete process.env.NODE_ENV;
|
||||
await expect(new AdminController(svc()).devTestNotification(user, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('sends in development', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = await new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' });
|
||||
expect(res).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('applies notification defaults when the body is empty', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
const res = await new AdminController(svc()).devTestNotification(user, {});
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(sendNotification).toHaveBeenCalledWith(expect.objectContaining({ event: 'trip_reminder', scope: 'user', targetId: user.id }));
|
||||
});
|
||||
|
||||
it('maps an Error from the notification service to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('send failed'));
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'send failed' } });
|
||||
});
|
||||
|
||||
it('stringifies a non-Error notification failure to 400', async () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
(sendNotification as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce('weird');
|
||||
await expect(new AdminController(svc()).devTestNotification(user, { event: 'trip_reminder' })).rejects.toMatchObject({ response: { error: 'weird' } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { AirportsController } from '../../../src/nest/airports/airports.controller';
|
||||
import type { AirportsService } from '../../../src/nest/airports/airports.service';
|
||||
import type { Airport } from '@trek/shared';
|
||||
|
||||
function makeController(svc: Partial<AirportsService>) {
|
||||
return new AirportsController(svc as AirportsService);
|
||||
}
|
||||
|
||||
const BER: Airport = {
|
||||
iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin',
|
||||
country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin',
|
||||
};
|
||||
|
||||
/** Run `fn`, expecting an HttpException; return its { status, body }. */
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
describe('AirportsController (parity with the legacy /api/airports route)', () => {
|
||||
describe('GET /api/airports/search', () => {
|
||||
it('returns [] without calling the service when the query is absent', () => {
|
||||
const search = vi.fn();
|
||||
const res = makeController({ search }).search(undefined);
|
||||
expect(res).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] for an empty query', () => {
|
||||
const search = vi.fn();
|
||||
expect(makeController({ search }).search('')).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns [] when the query arrives as an array (Express typeof guard)', () => {
|
||||
const search = vi.fn();
|
||||
expect(makeController({ search }).search(['a', 'b'])).toEqual([]);
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates a non-empty query to the service and returns its result', () => {
|
||||
const search = vi.fn().mockReturnValue([BER]);
|
||||
const res = makeController({ search }).search('ber');
|
||||
expect(res).toEqual([BER]);
|
||||
expect(search).toHaveBeenCalledWith('ber');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/airports/:iata', () => {
|
||||
it('returns the airport when found', () => {
|
||||
const findByIata = vi.fn().mockReturnValue(BER);
|
||||
expect(makeController({ findByIata }).findByIata('BER')).toEqual(BER);
|
||||
expect(findByIata).toHaveBeenCalledWith('BER');
|
||||
});
|
||||
|
||||
it('404 { error } with the exact legacy message when not found', () => {
|
||||
const findByIata = vi.fn().mockReturnValue(null);
|
||||
expect(thrown(() => makeController({ findByIata }).findByIata('ZZZ'))).toEqual({
|
||||
status: 404,
|
||||
body: { error: 'Airport not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DayAssignmentsController, AssignmentOpsController } from '../../../src/nest/assignments/assignments.controller';
|
||||
import type { AssignmentsService } from '../../../src/nest/assignments/assignments.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { user_id: 1 };
|
||||
|
||||
function svc(o: Partial<AssignmentsService> = {}): AssignmentsService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(),
|
||||
dayExists: vi.fn().mockReturnValue(true), placeExists: vi.fn().mockReturnValue(true), notifyPlaceCreated: vi.fn(),
|
||||
...o,
|
||||
} as unknown as AssignmentsService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
describe('DayAssignmentsController (parity with the legacy day-assignments routes)', () => {
|
||||
it('404 trip, then 404 day on GET', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).list(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
});
|
||||
|
||||
it('GET returns assignments (access-only, no permission gate)', () => {
|
||||
const s = svc({ canEdit: vi.fn().mockReturnValue(false), listDayAssignments: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AssignmentsService>);
|
||||
expect(new DayAssignmentsController(s).list(user, '5', '3')).toEqual({ assignments: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('403 without day_edit; 404 place not found; then creates + hooks', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ placeExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).create(user, '5', '3', { place_id: 2 }))).toEqual({ status: 404, body: { error: 'Place not found' } });
|
||||
const createAssignment = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn(); const notifyPlaceCreated = vi.fn();
|
||||
const s = svc({ createAssignment, broadcast, notifyPlaceCreated } as Partial<AssignmentsService>);
|
||||
expect(new DayAssignmentsController(s).create(user, '5', '3', { place_id: 2, notes: 'n' }, 'sock')).toEqual({ assignment: { id: 9 } });
|
||||
expect(createAssignment).toHaveBeenCalledWith('3', 2, 'n');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:created', { assignment: { id: 9 } }, 'sock');
|
||||
expect(notifyPlaceCreated).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /reorder 404 day, else reorders + broadcasts', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).reorder(user, '5', '3', [1, 2]))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const reorderAssignments = vi.fn(); const broadcast = vi.fn();
|
||||
expect(new DayAssignmentsController(svc({ reorderAssignments, broadcast } as Partial<AssignmentsService>)).reorder(user, '5', '3', [2, 1], 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:reordered', { dayId: 3, orderedIds: [2, 1] }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when not in day, else success', () => {
|
||||
expect(thrown(() => new DayAssignmentsController(svc({ assignmentExistsInDay: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
const s = svc({ assignmentExistsInDay: vi.fn().mockReturnValue(true), deleteAssignment: vi.fn() } as Partial<AssignmentsService>);
|
||||
expect(new DayAssignmentsController(s).remove(user, '5', '3', '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('AssignmentOpsController (parity with the per-assignment op routes)', () => {
|
||||
it('PUT /:id/move 404 assignment, 404 target day, else moves', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), dayExists: vi.fn().mockReturnValue(false) } as Partial<AssignmentsService>)).move(user, '5', '9', { new_day_id: 4 }))).toEqual({ status: 404, body: { error: 'Target day not found' } });
|
||||
const moveAssignment = vi.fn().mockReturnValue({ assignment: { id: 9 } }); const broadcast = vi.fn();
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ day_id: 3 }), moveAssignment, broadcast } as Partial<AssignmentsService>);
|
||||
expect(new AssignmentOpsController(s).move(user, '5', '9', { new_day_id: 4, order_index: 0 }, 'sock')).toEqual({ assignment: { id: 9 } });
|
||||
expect(moveAssignment).toHaveBeenCalledWith('9', 4, 0, 3);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:moved', { assignment: { id: 9 }, oldDayId: 3, newDayId: 4 }, 'sock');
|
||||
});
|
||||
|
||||
it('GET /:id/participants returns participants (access-only)', () => {
|
||||
const s = svc({ getParticipants: vi.fn().mockReturnValue([{ user_id: 2 }]) } as Partial<AssignmentsService>);
|
||||
expect(new AssignmentOpsController(s).participants(user, '5', '9')).toEqual({ participants: [{ user_id: 2 }] });
|
||||
});
|
||||
|
||||
it('PUT /:id/time 404 missing, else updates', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc({ getAssignmentForTrip: vi.fn().mockReturnValue(undefined) } as Partial<AssignmentsService>)).time(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Assignment not found' } });
|
||||
const updateTime = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
|
||||
const s = svc({ getAssignmentForTrip: vi.fn().mockReturnValue({ id: 9 }), updateTime, broadcast } as Partial<AssignmentsService>);
|
||||
expect(new AssignmentOpsController(s).time(user, '5', '9', { place_time: '10:00' }, 'sock')).toEqual({ assignment: { id: 9 } });
|
||||
expect(updateTime).toHaveBeenCalledWith('9', '10:00', undefined);
|
||||
});
|
||||
|
||||
it('PUT /:id/participants 400 not array, else sets + broadcasts', () => {
|
||||
expect(thrown(() => new AssignmentOpsController(svc()).setParticipants(user, '5', '9', 'no'))).toEqual({ status: 400, body: { error: 'user_ids must be an array' } });
|
||||
const setParticipants = vi.fn().mockReturnValue([{ user_id: 2 }]); const broadcast = vi.fn();
|
||||
expect(new AssignmentOpsController(svc({ setParticipants, broadcast } as Partial<AssignmentsService>)).setParticipants(user, '5', '9', [2], 'sock')).toEqual({ participants: [{ user_id: 2 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'assignment:participants', { assignmentId: 9, participants: [{ user_id: 2 }] }, 'sock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AtlasController } from '../../../src/nest/atlas/atlas.controller';
|
||||
import type { AtlasService } from '../../../src/nest/atlas/atlas.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 8 } as User;
|
||||
|
||||
function makeController(svc: Partial<AtlasService>) {
|
||||
return new AtlasController(svc as AtlasService);
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return { setHeader: vi.fn() } as unknown as Response & { setHeader: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
async function thrown(fn: () => unknown): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
describe('AtlasController (parity with the legacy /api/addons/atlas route)', () => {
|
||||
it('GET /stats delegates with the user id', () => {
|
||||
const stats = vi.fn().mockReturnValue({ countries: 3 });
|
||||
expect(makeController({ stats }).stats(user)).toEqual({ countries: 3 });
|
||||
expect(stats).toHaveBeenCalledWith(8);
|
||||
});
|
||||
|
||||
describe('GET /regions/geo', () => {
|
||||
it('returns an empty FeatureCollection without a cache header when no countries given', async () => {
|
||||
const regionGeo = vi.fn();
|
||||
const res = makeRes();
|
||||
const out = await makeController({ regionGeo }).regionGeo(undefined, res);
|
||||
expect(out).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
expect(regionGeo).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('caches a non-empty result for a day', async () => {
|
||||
const regionGeo = vi.fn().mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] });
|
||||
const res = makeRes();
|
||||
const out = await makeController({ regionGeo }).regionGeo('DE,FR', res);
|
||||
expect(out).toEqual({ type: 'FeatureCollection', features: [{ id: 1 }] });
|
||||
expect(regionGeo).toHaveBeenCalledWith(['DE', 'FR']);
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'public, max-age=86400');
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /countries/geo delegates to the service', () => {
|
||||
const fc = { type: 'FeatureCollection', features: [{ id: 'NO' }] };
|
||||
const countryGeo = vi.fn().mockReturnValue(fc);
|
||||
expect(makeController({ countryGeo }).countryGeo()).toBe(fc);
|
||||
expect(countryGeo).toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
describe('country', () => {
|
||||
it('GET /country/:code upper-cases the code', () => {
|
||||
const countryPlaces = vi.fn().mockReturnValue([]);
|
||||
makeController({ countryPlaces }).countryPlaces(user, 'de');
|
||||
expect(countryPlaces).toHaveBeenCalledWith(8, 'DE');
|
||||
});
|
||||
|
||||
it('POST mark returns success and upper-cases', () => {
|
||||
const markCountry = vi.fn();
|
||||
expect(makeController({ markCountry }).markCountry(user, 'de')).toEqual({ success: true });
|
||||
expect(markCountry).toHaveBeenCalledWith(8, 'DE');
|
||||
});
|
||||
|
||||
it('DELETE mark returns success', () => {
|
||||
const unmarkCountry = vi.fn();
|
||||
expect(makeController({ unmarkCountry }).unmarkCountry(user, 'FR')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('region', () => {
|
||||
it('400 when name or country_code is missing', () => {
|
||||
const markRegion = vi.fn();
|
||||
return thrown(() => makeController({ markRegion }).markRegion(user, 'by', undefined, 'DE')).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'name and country_code are required' } }));
|
||||
});
|
||||
|
||||
it('marks a region, upper-casing both codes', () => {
|
||||
const markRegion = vi.fn();
|
||||
expect(makeController({ markRegion }).markRegion(user, 'by', 'Bavaria', 'de')).toEqual({ success: true });
|
||||
expect(markRegion).toHaveBeenCalledWith(8, 'BY', 'Bavaria', 'DE');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bucket list', () => {
|
||||
it('GET wraps the items', () => {
|
||||
const bucketList = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(makeController({ bucketList }).bucketList(user)).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('400 on create with a blank name', () => {
|
||||
const createBucketItem = vi.fn();
|
||||
return thrown(() => makeController({ createBucketItem }).createBucketItem(user, { name: ' ' })).then((r) =>
|
||||
expect(r).toEqual({ status: 400, body: { error: 'Name is required' } }));
|
||||
});
|
||||
|
||||
it('201-shape create returns { item }', () => {
|
||||
const createBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Tokyo' });
|
||||
expect(makeController({ createBucketItem }).createBucketItem(user, { name: 'Tokyo', lat: 35, lng: 139 }))
|
||||
.toEqual({ item: { id: 1, name: 'Tokyo' } });
|
||||
expect(createBucketItem).toHaveBeenCalledWith(8, { name: 'Tokyo', lat: 35, lng: 139, country_code: undefined, notes: undefined, target_date: undefined });
|
||||
});
|
||||
|
||||
it('404 on update of a missing item', () => {
|
||||
const updateBucketItem = vi.fn().mockReturnValue(null);
|
||||
return thrown(() => makeController({ updateBucketItem }).updateBucketItem(user, '9', { name: 'X' })).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
|
||||
});
|
||||
|
||||
it('updates an existing item', () => {
|
||||
const updateBucketItem = vi.fn().mockReturnValue({ id: 1, name: 'Kyoto' });
|
||||
expect(makeController({ updateBucketItem }).updateBucketItem(user, '1', { name: 'Kyoto' }))
|
||||
.toEqual({ item: { id: 1, name: 'Kyoto' } });
|
||||
});
|
||||
|
||||
it('404 on delete of a missing item', () => {
|
||||
const deleteBucketItem = vi.fn().mockReturnValue(false);
|
||||
return thrown(() => makeController({ deleteBucketItem }).deleteBucketItem(user, '9')).then((r) =>
|
||||
expect(r).toEqual({ status: 404, body: { error: 'Item not found' } }));
|
||||
});
|
||||
|
||||
it('deletes an existing item', () => {
|
||||
const deleteBucketItem = vi.fn().mockReturnValue(true);
|
||||
expect(makeController({ deleteBucketItem }).deleteBucketItem(user, '1')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
vi.mock('../../../src/middleware/auth', () => ({ extractToken: vi.fn(), verifyJwtAndLoadUser: vi.fn() }));
|
||||
vi.mock('../../../src/services/authService', () => ({ resolveAuthToggles: vi.fn() }));
|
||||
vi.mock('../../../src/services/cookie', () => ({ setAuthCookie: vi.fn() }));
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/passkeyService', () => ({
|
||||
passkeyRegisterOptions: vi.fn(),
|
||||
passkeyRegisterVerify: vi.fn(),
|
||||
passkeyLoginOptions: vi.fn(),
|
||||
passkeyLoginVerify: vi.fn(),
|
||||
listPasskeys: vi.fn(),
|
||||
renamePasskey: vi.fn(),
|
||||
deletePasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
|
||||
import { CookieAuthGuard } from '../../../src/nest/auth/cookie-auth.guard';
|
||||
import { OptionalJwtGuard } from '../../../src/nest/auth/optional-jwt.guard';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import { PasskeyEnabledGuard } from '../../../src/nest/auth/passkey-enabled.guard';
|
||||
import { PasskeyController } from '../../../src/nest/auth/passkey.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import { CurrentUser } from '../../../src/nest/auth/current-user.decorator';
|
||||
import { extractToken, verifyJwtAndLoadUser } from '../../../src/middleware/auth';
|
||||
import { resolveAuthToggles } from '../../../src/services/authService';
|
||||
import { setAuthCookie } from '../../../src/services/cookie';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as passkey from '../../../src/services/passkeyService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function context(req: unknown) {
|
||||
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
|
||||
}
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('JwtAuthGuard', () => {
|
||||
const guard = new JwtAuthGuard();
|
||||
|
||||
it('rejects with the legacy 401 { error, code } when no token is present', () => {
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {}, cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Access token required', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an invalid/expired token (verify returns null)', () => {
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ headers: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired token', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the loaded user and allows a valid token through', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CookieAuthGuard', () => {
|
||||
const guard = new CookieAuthGuard();
|
||||
|
||||
it('401s when the trek_session cookie is missing', () => {
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: {} })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
// and when there is no cookies object at all
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('401s when the cookie token fails verification', () => {
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(thrown(() => guard.canActivate(context({ cookies: { trek_session: 'tok' } })))).toEqual({
|
||||
status: 401,
|
||||
body: { error: 'Invalid or expired session', code: 'AUTH_REQUIRED' },
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the user and allows a valid cookie session through', () => {
|
||||
const req: Record<string, unknown> = { cookies: { trek_session: 'tok' } };
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OptionalJwtGuard', () => {
|
||||
const guard = new OptionalJwtGuard();
|
||||
|
||||
it('always allows; sets req.user to null when no token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
expect(verifyJwtAndLoadUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets req.user to null when a token verifies to nothing', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(null);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBeNull();
|
||||
});
|
||||
|
||||
it('populates req.user from a valid token', () => {
|
||||
const req: Record<string, unknown> = { headers: {} };
|
||||
vi.mocked(extractToken).mockReturnValue('tok');
|
||||
vi.mocked(verifyJwtAndLoadUser).mockReturnValue(user);
|
||||
expect(guard.canActivate(context(req))).toBe(true);
|
||||
expect(req.user).toBe(user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const guard = new AdminGuard();
|
||||
|
||||
it('403s for anonymous and for a non-admin role', () => {
|
||||
expect(thrown(() => guard.canActivate(context({})))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
expect(thrown(() => guard.canActivate(context({ user: { role: 'user' } })))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
});
|
||||
|
||||
it('allows an admin through', () => {
|
||||
expect(guard.canActivate(context({ user: { role: 'admin' } }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyEnabledGuard', () => {
|
||||
const guard = new PasskeyEnabledGuard();
|
||||
|
||||
it('404s when passkey_login is off', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: false } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(thrown(() => guard.canActivate())).toEqual({ status: 404, body: { error: 'Passkey login is not enabled' } });
|
||||
});
|
||||
|
||||
it('allows when passkey_login is on', () => {
|
||||
vi.mocked(resolveAuthToggles).mockReturnValue({ passkey_login: true } as ReturnType<typeof resolveAuthToggles>);
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CurrentUser decorator', () => {
|
||||
// Apply the decorator to a throwaway handler so Nest stores the param factory in
|
||||
// route metadata, then invoke that factory exactly as the framework would.
|
||||
function paramFactory(): (data: unknown, ctx: unknown) => User | undefined {
|
||||
class Target { handler(_u: User) {} }
|
||||
(CurrentUser() as ParameterDecorator)(Target.prototype, 'handler', 0);
|
||||
const meta = Reflect.getMetadata('__routeArguments__', Target, 'handler') as Record<string, { factory: (data: unknown, ctx: unknown) => User | undefined }>;
|
||||
return Object.values(meta)[0].factory;
|
||||
}
|
||||
|
||||
it('resolves the authenticated user from the request', () => {
|
||||
expect(paramFactory()(undefined, context({ user }))).toBe(user);
|
||||
});
|
||||
|
||||
it('returns undefined when no user is attached', () => {
|
||||
expect(paramFactory()(undefined, context({}))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasskeyController', () => {
|
||||
const req = { ip: '9.9.9.9' } as Request;
|
||||
const res = {} as never;
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
|
||||
it('register/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerOptions(user, { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.passkeyRegisterOptions).mockResolvedValue({ options: { challenge: 'c' } as never });
|
||||
expect(await new PasskeyController(rl()).registerOptions(user, { password: 'p' }, req)).toEqual({ challenge: 'c' });
|
||||
});
|
||||
|
||||
it('register/verify maps a service error, else audits and returns the credential', async () => {
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ error: 'Verification failed', status: 400 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).registerVerify(user, {}, req))).toEqual({ status: 400, body: { error: 'Verification failed' } });
|
||||
vi.mocked(passkey.passkeyRegisterVerify).mockResolvedValue({ credential: { id: 'cr' } } as never);
|
||||
expect(await new PasskeyController(rl()).registerVerify(user, {}, req)).toEqual({ success: true, credential: { id: 'cr' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_register' }));
|
||||
});
|
||||
|
||||
it('login/options maps a service error, else returns the options', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ error: 'Not configured', status: 503 } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginOptions(req))).toEqual({ status: 503, body: { error: 'Not configured' } });
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'd' } } as never);
|
||||
expect(await new PasskeyController(rl()).loginOptions(req)).toEqual({ challenge: 'd' });
|
||||
});
|
||||
|
||||
it('login/verify audits a failure then maps the error, padding latency', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ error: 'No match', status: 401, auditAction: 'user.login_fail', auditUserId: null } as never);
|
||||
expect(await thrownAsync(() => new PasskeyController(rl()).loginVerify({}, req, res))).toEqual({ status: 401, body: { error: 'No match' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_fail' }));
|
||||
}, 10000);
|
||||
|
||||
it('login/verify sets the session cookie and audits login on success', async () => {
|
||||
vi.mocked(passkey.passkeyLoginVerify).mockResolvedValue({ token: 'tk', user, auditUserId: 1 } as never);
|
||||
expect(await new PasskeyController(rl()).loginVerify({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login', details: { method: 'passkey' } }));
|
||||
}, 10000);
|
||||
|
||||
it('credentials: list, rename (error + success), delete (error + success)', () => {
|
||||
vi.mocked(passkey.listPasskeys).mockReturnValue([{ id: 'a' }]);
|
||||
expect(new PasskeyController(rl()).list(user)).toEqual({ credentials: [{ id: 'a' }] });
|
||||
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ error: 'Not found', status: 404 });
|
||||
expect(thrown(() => new PasskeyController(rl()).rename(user, 'cid', { name: 'x' }))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
vi.mocked(passkey.renamePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).rename(user, 'cid', { name: 'x' })).toEqual({ success: true });
|
||||
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ error: 'Incorrect password', status: 401 });
|
||||
expect(thrown(() => new PasskeyController(rl()).remove(user, 'cid', { password: 'x' }, req))).toEqual({ status: 401, body: { error: 'Incorrect password' } });
|
||||
vi.mocked(passkey.deletePasskey).mockReturnValue({ success: true });
|
||||
expect(new PasskeyController(rl()).remove(user, 'cid', { password: 'p' }, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.passkey_delete' }));
|
||||
});
|
||||
|
||||
it('throttles registration and login ceremonies once the bucket is exhausted', async () => {
|
||||
const s = new RateLimitService();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('mfa', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s).registerOptions(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
|
||||
const s2 = new RateLimitService();
|
||||
for (let i = 0; i < 10; i++) s2.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
expect(await thrownAsync(() => new PasskeyController(s2).loginOptions(req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('falls back to the "unknown" rate-limit key when req.ip is absent', async () => {
|
||||
vi.mocked(passkey.passkeyLoginOptions).mockResolvedValue({ options: { challenge: 'z' } } as never);
|
||||
const noIp = {} as Request;
|
||||
expect(await new PasskeyController(rl()).loginOptions(noIp)).toEqual({ challenge: 'z' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
import { AuthPublicController } from '../../../src/nest/auth/auth-public.controller';
|
||||
import { AuthController } from '../../../src/nest/auth/auth.controller';
|
||||
import { RateLimitService } from '../../../src/nest/auth/rate-limit.service';
|
||||
import type { AuthService } from '../../../src/nest/auth/auth.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import { isDemoEmail } from '../../../src/services/demo';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
const req = { ip: '9.9.9.9', headers: {} } as Request;
|
||||
const res = {} as Response;
|
||||
|
||||
function asvc(o: Partial<AuthService> = {}): AuthService {
|
||||
return { setAuthCookie: vi.fn(), clearAuthCookie: vi.fn(), getAppUrl: vi.fn(() => 'https://x'), sendPasswordResetEmail: vi.fn(), ...o } as unknown as AuthService;
|
||||
}
|
||||
function rl(): RateLimitService { return new RateLimitService(); }
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.DEMO_MODE; });
|
||||
|
||||
describe('RateLimitService', () => {
|
||||
it('allows up to max then blocks within the window; buckets are isolated', () => {
|
||||
const s = rl();
|
||||
expect(s.check('login', 'ip', 2, 1000, 0)).toBe(true);
|
||||
expect(s.check('login', 'ip', 2, 1000, 10)).toBe(true);
|
||||
expect(s.check('login', 'ip', 2, 1000, 20)).toBe(false); // 3rd within window
|
||||
expect(s.check('mfa', 'ip', 2, 1000, 20)).toBe(true); // different bucket
|
||||
expect(s.check('login', 'ip', 2, 1000, 2000)).toBe(true); // window elapsed -> reset
|
||||
});
|
||||
|
||||
it('reset clears a single named bucket, and reset() clears all of them', () => {
|
||||
const s = rl();
|
||||
s.check('login', 'ip', 1, 1000, 0); // login bucket now at its cap
|
||||
s.check('mfa', 'ip', 1, 1000, 0); // mfa bucket now at its cap
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(false);
|
||||
s.reset('login'); // only the login bucket
|
||||
expect(s.check('login', 'ip', 1, 1000, 0)).toBe(true);
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(false); // mfa untouched
|
||||
s.reset(); // everything
|
||||
expect(s.check('mfa', 'ip', 1, 1000, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthPublicController', () => {
|
||||
it('demo-login maps error, else sets the cookie + returns token/user', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ error: 'Demo disabled', status: 403 }) } as Partial<AuthService>), rl()).demoLogin(req, res))).toEqual({ status: 403, body: { error: 'Demo disabled' } });
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ demoLogin: vi.fn().mockReturnValue({ token: 'tk', user }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.demoLogin(req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req);
|
||||
});
|
||||
|
||||
it('register audits + sets cookie; maps error', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AuthService>), rl()).register({}, req, res))).toEqual({ status: 409, body: { error: 'Email taken' } });
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ registerUser: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1, auditDetails: {} }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.register({ email: 'a@b.c', password: 'p' }, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.register' }));
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invite 429 when rate-limited', () => {
|
||||
const s = rl();
|
||||
s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, Date.now()); // not exhausted yet
|
||||
const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }) } as Partial<AuthService>), s);
|
||||
expect(c.invite('tok', req)).toEqual({ valid: true, max_uses: 1, used_count: 0, expires_at: null });
|
||||
});
|
||||
|
||||
it('login: mfa branch, success cookie, error mapping', async () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const mfa = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt' }) } as Partial<AuthService>), rl());
|
||||
expect(await mfa.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
const ok = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ token: 'tk', user, remember: true }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await ok.login({}, req, res)).toEqual({ token: 'tk', user });
|
||||
// The "remember me" flag from the service rides through to the cookie service.
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk', req, true);
|
||||
const bad = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ error: 'Bad creds', status: 401, auditAction: 'user.login_fail' }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => bad.login({}, req, res))).toEqual({ status: 401, body: { error: 'Bad creds' } });
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password issues a reset email then returns the generic ok', async () => {
|
||||
const sendPasswordResetEmail = vi.fn().mockResolvedValue({ delivered: true });
|
||||
const c = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail } as Partial<AuthService>), rl());
|
||||
expect(await c.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
||||
expect(sendPasswordResetEmail).toHaveBeenCalledWith('a@b.c', 'https://x/reset-password?token=rt', 1);
|
||||
}, 10000);
|
||||
|
||||
it('reset-password: error audits a fail, mfa branch, success', () => {
|
||||
expect(thrown(() => new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ error: 'Invalid token', status: 400 }) } as Partial<AuthService>), rl()).resetPassword({}, req))).toEqual({ status: 400, body: { error: 'Invalid token' } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_fail' }));
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ mfa_required: true }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ mfa_required: true });
|
||||
expect(new AuthPublicController(asvc({ resetPassword: vi.fn().mockReturnValue({ userId: 1 }) } as Partial<AuthService>), rl()).resetPassword({}, req)).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('app-config forwards the optional user (present and absent)', () => {
|
||||
const getAppConfig = vi.fn().mockReturnValue({ version: '3' });
|
||||
const c = new AuthPublicController(asvc({ getAppConfig } as Partial<AuthService>), rl());
|
||||
expect(c.appConfig({ user } as unknown as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(user);
|
||||
expect(c.appConfig({} as Request)).toEqual({ version: '3' });
|
||||
expect(getAppConfig).toHaveBeenLastCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('invite maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ validateInviteToken: vi.fn().mockReturnValue({ error: 'Expired', status: 410 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.invite('tok', req))).toEqual({ status: 410, body: { error: 'Expired' } });
|
||||
});
|
||||
|
||||
it('login takes the mfa-required branch and never sets a cookie', async () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ loginUser: vi.fn().mockReturnValue({ mfa_required: true, mfa_token: 'mt', auditAction: 'user.login_mfa' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(await c.login({}, req, res)).toEqual({ mfa_required: true, mfa_token: 'mt' });
|
||||
expect(setAuthCookie).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.login_mfa' }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password: non-issued reason and a delivery failure both still return ok', async () => {
|
||||
// Non-issued (unknown email / throttled): audits the reason, no email sent.
|
||||
const sendNever = vi.fn();
|
||||
const skip = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'not_found', userId: null }), sendPasswordResetEmail: sendNever } as Partial<AuthService>), rl());
|
||||
expect(await skip.forgotPassword({ email: 'x@y.z' }, req)).toEqual({ ok: true });
|
||||
expect(sendNever).not.toHaveBeenCalled();
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_reset_request', details: { reason: 'not_found' } }));
|
||||
// Issued but the mailer throws: swallowed, audited as failed, still ok.
|
||||
const boom = vi.fn().mockRejectedValue(new Error('smtp'));
|
||||
const fail = new AuthPublicController(asvc({ requestPasswordReset: vi.fn().mockReturnValue({ reason: 'issued', tokenForDelivery: 'rt', userEmail: 'a@b.c', userId: 1 }), sendPasswordResetEmail: boom } as Partial<AuthService>), rl());
|
||||
expect(await fail.forgotPassword({ email: 'a@b.c' }, req)).toEqual({ ok: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ details: { delivered: 'failed' } }));
|
||||
}, 10000);
|
||||
|
||||
it('forgot-password ignores a non-string email body', async () => {
|
||||
const requestPasswordReset = vi.fn().mockReturnValue({ reason: 'not_found', userId: null });
|
||||
const c = new AuthPublicController(asvc({ requestPasswordReset } as Partial<AuthService>), rl());
|
||||
expect(await c.forgotPassword({ email: 42 } as { email?: unknown }, req)).toEqual({ ok: true });
|
||||
expect(requestPasswordReset).toHaveBeenCalledWith('', expect.any(String));
|
||||
}, 10000);
|
||||
|
||||
it('reset-password 429 once the dedicated reset bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 5; i++) s.check('reset', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ resetPassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.resetPassword({}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login maps a service error', () => {
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ error: 'Bad code', status: 401 }) } as Partial<AuthService>), rl());
|
||||
expect(thrown(() => c.verifyMfaLogin({}, req, res))).toEqual({ status: 401, body: { error: 'Bad code' } });
|
||||
});
|
||||
|
||||
it('demo-login + register + invite throw 429 when the login bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 10; i++) s.check('login', '9.9.9.9', 10, 15 * 60 * 1000, now);
|
||||
const c = new AuthPublicController(asvc({ registerUser: vi.fn(), validateInviteToken: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.register({}, req, res))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
expect(thrown(() => c.invite('t', req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('mfa/verify-login sets cookie + audits; logout clears cookie', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthPublicController(asvc({ verifyMfaLogin: vi.fn().mockReturnValue({ token: 'tk', user, auditUserId: 1 }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.verifyMfaLogin({}, req, res)).toEqual({ token: 'tk', user });
|
||||
expect(setAuthCookie).toHaveBeenCalled();
|
||||
const clearAuthCookie = vi.fn();
|
||||
expect(new AuthPublicController(asvc({ clearAuthCookie } as Partial<AuthService>), rl()).logout(req, res)).toEqual({ success: true });
|
||||
expect(clearAuthCookie).toHaveBeenCalledWith(res, req);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthController (authenticated)', () => {
|
||||
it('GET /me 404 when missing, else returns the loaded user', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue(undefined) } as Partial<AuthService>), rl()).me(user))).toEqual({ status: 404, body: { error: 'User not found' } });
|
||||
expect(new AuthController(asvc({ getCurrentUser: vi.fn().mockReturnValue({ id: 1 }) } as Partial<AuthService>), rl()).me(user)).toEqual({ user: { id: 1 } });
|
||||
});
|
||||
|
||||
it('change-password maps error, else audits', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ error: 'Wrong', status: 400 }) } as Partial<AuthService>), rl()).changePassword(user, {}, req))).toEqual({ status: 400, body: { error: 'Wrong' } });
|
||||
expect(new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).changePassword(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.password_change' }));
|
||||
});
|
||||
|
||||
it('avatar 403 in demo mode, 400 without a file, else saves', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(true);
|
||||
expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
||||
vi.mocked(isDemoEmail).mockReturnValue(false);
|
||||
delete process.env.DEMO_MODE;
|
||||
expect(await thrownAsync(() => new AuthController(asvc(), rl()).avatar(user, undefined))).toEqual({ status: 400, body: { error: 'No image uploaded' } });
|
||||
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/a.jpg' });
|
||||
expect(await new AuthController(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'a.jpg' } as Express.Multer.File)).toEqual({ avatar: '/a.jpg' });
|
||||
});
|
||||
|
||||
it('mfa/setup awaits the QR promise, maps a generation failure to 500', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ok = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.resolve('<svg>') }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.mfaSetup(user)).toEqual({ secret: 's', otpauth_url: 'o', qr_svg: '<svg>' });
|
||||
const fail = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ secret: 's', otpauth_url: 'o', qrPromise: Promise.reject(new Error('x')) }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => fail.mfaSetup(user))).toEqual({ status: 500, body: { error: 'Could not generate QR code' } });
|
||||
});
|
||||
|
||||
it('mfa/enable audits + returns backup codes; mcp-tokens create 201', () => {
|
||||
const enable = new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ mfa_enabled: true, backup_codes: ['a', 'b'] }) } as Partial<AuthService>), rl());
|
||||
expect(enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' }));
|
||||
const tok = new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) } as Partial<AuthService>), rl());
|
||||
expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' });
|
||||
});
|
||||
|
||||
it('resource-token 503 when unavailable, else returns the token payload', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue(null) } as Partial<AuthService>), rl()).resourceToken(user, {}))).toEqual({ status: 503, body: { error: 'Service unavailable' } });
|
||||
expect(new AuthController(asvc({ createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) } as Partial<AuthService>), rl()).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' });
|
||||
});
|
||||
|
||||
it('rate-limited account ops throw 429 once the bucket is exhausted', () => {
|
||||
const s = rl();
|
||||
const now = Date.now();
|
||||
// exhaust the shared 'login' bucket for this ip (max 5)
|
||||
for (let i = 0; i < 5; i++) s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now);
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn() } as Partial<AuthService>), s);
|
||||
expect(thrown(() => c.changePassword(user, {}, req))).toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
|
||||
});
|
||||
|
||||
it('change-password refreshes this device cookie when the service returns a token', () => {
|
||||
const setAuthCookie = vi.fn();
|
||||
const c = new AuthController(asvc({ changePassword: vi.fn().mockReturnValue({ token: 'tk2' }), setAuthCookie } as Partial<AuthService>), rl());
|
||||
expect(c.changePassword(user, {}, req, res)).toEqual({ success: true });
|
||||
expect(setAuthCookie).toHaveBeenCalledWith(res, 'tk2', req);
|
||||
});
|
||||
|
||||
it('delete-account maps error, else audits and succeeds', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({ error: 'Last admin', status: 403 }) } as Partial<AuthService>), rl()).deleteAccount(user, req))).toEqual({ status: 403, body: { error: 'Last admin' } });
|
||||
expect(new AuthController(asvc({ deleteAccount: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteAccount(user, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.account_delete' }));
|
||||
});
|
||||
|
||||
it('maps-key + api-keys pass straight through to the service', () => {
|
||||
const updateMapsKey = vi.fn().mockReturnValue({ success: true });
|
||||
expect(new AuthController(asvc({ updateMapsKey } as Partial<AuthService>), rl()).mapsKey(user, { maps_api_key: 'k' })).toEqual({ success: true });
|
||||
expect(updateMapsKey).toHaveBeenCalledWith(1, 'k');
|
||||
const updateApiKeys = vi.fn().mockReturnValue({ ok: 1 });
|
||||
expect(new AuthController(asvc({ updateApiKeys } as Partial<AuthService>), rl()).apiKeys(user, { a: 1 })).toEqual({ ok: 1 });
|
||||
});
|
||||
|
||||
it('update-settings + get-settings map errors, else return their payloads', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ error: 'Bad', status: 400 }) } as Partial<AuthService>), rl()).updateSettings(user, {}))).toEqual({ status: 400, body: { error: 'Bad' } });
|
||||
expect(new AuthController(asvc({ updateSettings: vi.fn().mockReturnValue({ success: true, user: { id: 1 } }) } as Partial<AuthService>), rl()).updateSettings(user, {})).toEqual({ success: true, user: { id: 1 } });
|
||||
expect(thrown(() => new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ error: 'Nope', status: 404 }) } as Partial<AuthService>), rl()).getSettings(user))).toEqual({ status: 404, body: { error: 'Nope' } });
|
||||
expect(new AuthController(asvc({ getSettings: vi.fn().mockReturnValue({ settings: { theme: 'dark' } }) } as Partial<AuthService>), rl()).getSettings(user)).toEqual({ settings: { theme: 'dark' } });
|
||||
});
|
||||
|
||||
it('delete-avatar + users + travel-stats delegate to the service', async () => {
|
||||
const deleteAvatar = vi.fn().mockResolvedValue({ removed: true });
|
||||
expect(await new AuthController(asvc({ deleteAvatar } as Partial<AuthService>), rl()).deleteAvatar(user)).toEqual({ removed: true });
|
||||
const listUsers = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new AuthController(asvc({ listUsers } as Partial<AuthService>), rl()).users(user)).toEqual({ users: [{ id: 1 }] });
|
||||
expect(listUsers).toHaveBeenCalledWith(1);
|
||||
const getTravelStats = vi.fn().mockReturnValue({ countries: 3 });
|
||||
expect(new AuthController(asvc({ getTravelStats } as Partial<AuthService>), rl()).travelStats(user)).toEqual({ countries: 3 });
|
||||
});
|
||||
|
||||
it('validate-keys maps error, else returns the maps/weather payload', async () => {
|
||||
expect(await thrownAsync(() => new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ error: 'fail', status: 502 }) } as Partial<AuthService>), rl()).validateKeys(user))).toEqual({ status: 502, body: { error: 'fail' } });
|
||||
const ok = new AuthController(asvc({ validateKeys: vi.fn().mockResolvedValue({ maps: true, weather: false, maps_details: { ok: 1 } }) } as Partial<AuthService>), rl());
|
||||
expect(await ok.validateKeys(user)).toEqual({ maps: true, weather: false, maps_details: { ok: 1 } });
|
||||
});
|
||||
|
||||
it('app-settings get maps error, else returns data; put maps error, else audits', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ error: 'denied', status: 403 }) } as Partial<AuthService>), rl()).getAppSettings(user))).toEqual({ status: 403, body: { error: 'denied' } });
|
||||
expect(new AuthController(asvc({ getAppSettings: vi.fn().mockReturnValue({ data: { x: 1 } }) } as Partial<AuthService>), rl()).getAppSettings(user)).toEqual({ x: 1 });
|
||||
expect(thrown(() => new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ error: 'bad', status: 400 }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req))).toEqual({ status: 400, body: { error: 'bad' } });
|
||||
expect(new AuthController(asvc({ updateAppSettings: vi.fn().mockReturnValue({ auditSummary: 's', auditDebugDetails: 'd' }) } as Partial<AuthService>), rl()).updateAppSettings(user, {}, req)).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'settings.app_update' }));
|
||||
});
|
||||
|
||||
it('mfa/setup maps a service error before ever awaiting the QR promise', async () => {
|
||||
const c = new AuthController(asvc({ setupMfa: vi.fn().mockReturnValue({ error: 'already on', status: 409 }) } as Partial<AuthService>), rl());
|
||||
expect(await thrownAsync(() => c.mfaSetup(user))).toEqual({ status: 409, body: { error: 'already on' } });
|
||||
});
|
||||
|
||||
it('mfa/enable + mfa/disable map errors', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ enableMfa: vi.fn().mockReturnValue({ error: 'Invalid code', status: 400 }) } as Partial<AuthService>), rl()).mfaEnable(user, { code: 'x' }, req))).toEqual({ status: 400, body: { error: 'Invalid code' } });
|
||||
expect(thrown(() => new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ error: 'Wrong', status: 401 }) } as Partial<AuthService>), rl()).mfaDisable(user, {}, req))).toEqual({ status: 401, body: { error: 'Wrong' } });
|
||||
const ok = new AuthController(asvc({ disableMfa: vi.fn().mockReturnValue({ mfa_enabled: false }) } as Partial<AuthService>), rl());
|
||||
expect(ok.mfaDisable(user, {}, req)).toEqual({ success: true, mfa_enabled: false });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
|
||||
});
|
||||
|
||||
it('mcp-tokens list + create error + delete error/success', () => {
|
||||
expect(new AuthController(asvc({ listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) } as Partial<AuthService>), rl()).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
|
||||
expect(thrown(() => new AuthController(asvc({ createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) } as Partial<AuthService>), rl()).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
|
||||
expect(thrown(() => new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
expect(new AuthController(asvc({ deleteMcpToken: vi.fn().mockReturnValue({}) } as Partial<AuthService>), rl()).deleteMcpToken(user, 'tid')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('ws-token maps error, else returns the token', () => {
|
||||
expect(thrown(() => new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) } as Partial<AuthService>), rl()).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
|
||||
expect(new AuthController(asvc({ createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) } as Partial<AuthService>), rl()).wsToken(user)).toEqual({ token: 'ws' });
|
||||
});
|
||||
|
||||
it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(false);
|
||||
const saveAvatar = vi.fn().mockResolvedValue({ avatar: '/b.png' });
|
||||
expect(await new AuthController(asvc({ saveAvatar } as Partial<AuthService>), rl()).avatar(user, { filename: 'b.png' } as Express.Multer.File)).toEqual({ avatar: '/b.png' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
vi.mock('../../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
// The controller imports the tmp-dir + size cap at module load. The thin
|
||||
// BackupService wrapper forwards every call straight into this module, so the
|
||||
// mock also stubs the delegated functions for the wrapper tests below.
|
||||
vi.mock('../../../src/services/backupService', () => ({
|
||||
getUploadTmpDir: () => '/tmp',
|
||||
MAX_BACKUP_UPLOAD_SIZE: 1024,
|
||||
BACKUP_RATE_WINDOW: 3600000,
|
||||
listBackups: vi.fn().mockReturnValue([{ filename: 'svc.zip' }]),
|
||||
createBackup: vi.fn().mockResolvedValue({ filename: 'svc.zip', size: 5 }),
|
||||
restoreFromZip: vi.fn().mockResolvedValue({ success: true }),
|
||||
getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: false }, timezone: 'UTC' }),
|
||||
updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }),
|
||||
deleteBackup: vi.fn(),
|
||||
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
||||
backupFilePath: vi.fn().mockReturnValue('/data/backups/svc.zip'),
|
||||
backupFileExists: vi.fn().mockReturnValue(true),
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
import { BackupController } from '../../../src/nest/backup/backup.controller';
|
||||
import { BackupService as RealBackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
|
||||
import type { BackupService } from '../../../src/nest/backup/backup.service';
|
||||
import { writeAudit } from '../../../src/services/auditLog';
|
||||
import * as backupSvc from '../../../src/services/backupService';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'admin', email: 'a@example.test' } as User;
|
||||
const req = { ip: '1.2.3.4', headers: {} } as Request;
|
||||
|
||||
function svc(o: Partial<BackupService> = {}): BackupService {
|
||||
return {
|
||||
listBackups: vi.fn().mockReturnValue([]),
|
||||
createBackup: vi.fn(),
|
||||
restoreFromZip: vi.fn(),
|
||||
getAutoSettings: vi.fn(),
|
||||
updateAutoSettings: vi.fn(),
|
||||
deleteBackup: vi.fn(),
|
||||
isValidBackupFilename: vi.fn().mockReturnValue(true),
|
||||
backupFilePath: vi.fn().mockReturnValue('/b/x.zip'),
|
||||
backupFileExists: vi.fn().mockReturnValue(true),
|
||||
checkRateLimit: vi.fn().mockReturnValue(true),
|
||||
rateWindow: 3600000,
|
||||
...o,
|
||||
} as unknown as BackupService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.NODE_ENV; });
|
||||
|
||||
describe('AdminGuard (used by BackupController)', () => {
|
||||
function ctx(role?: string) {
|
||||
return { switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }) } as never;
|
||||
}
|
||||
it('403 for a non-admin, passes for an admin', () => {
|
||||
expect(thrown(() => new AdminGuard().canActivate(ctx('user')))).toEqual({ status: 403, body: { error: 'Admin access required' } });
|
||||
expect(new AdminGuard().canActivate(ctx('admin'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupController', () => {
|
||||
it('GET /list returns backups, 500 on error', () => {
|
||||
expect(new BackupController(svc({ listBackups: vi.fn().mockReturnValue([{ filename: 'a.zip' }]) } as Partial<BackupService>)).list()).toEqual({ backups: [{ filename: 'a.zip' }] });
|
||||
expect(thrown(() => new BackupController(svc({ listBackups: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).list())).toEqual({ status: 500, body: { error: 'Error loading backups' } });
|
||||
});
|
||||
|
||||
it('POST /create 429 when rate-limited, else creates + audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ checkRateLimit: vi.fn().mockReturnValue(false) })).create(user, req))).toEqual({ status: 429, body: { error: 'Too many backup requests. Please try again later.' } });
|
||||
const createBackup = vi.fn().mockResolvedValue({ filename: 'b.zip', size: 10 });
|
||||
const res = await new BackupController(svc({ createBackup } as Partial<BackupService>)).create(user, req);
|
||||
expect(res).toEqual({ success: true, backup: { filename: 'b.zip', size: 10 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.create', resource: 'b.zip' }));
|
||||
});
|
||||
|
||||
it('GET /download 400 invalid / 404 missing, else res.download', () => {
|
||||
const res = { download: vi.fn() } as unknown as Response;
|
||||
expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).download('x', res))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).download('x.zip', res))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
new BackupController(svc()).download('x.zip', res);
|
||||
expect(res.download).toHaveBeenCalledWith('/b/x.zip', 'x.zip');
|
||||
});
|
||||
|
||||
it('POST /restore maps the service status, else audits', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).restore(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).restore(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad zip' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 422, body: { error: 'bad zip' } });
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).restore(user, 'x.zip', req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.restore', resource: 'x.zip' }));
|
||||
});
|
||||
|
||||
it('POST /restore falls back to status 400 when the service omits one', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, error: 'nope' }) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 400, body: { error: 'nope' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore 400 without a file, cleans up the tmp file', async () => {
|
||||
expect(await thrownAsync(() => new BackupController(svc()).uploadRestore(user, undefined, req))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore success audits + reports', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
|
||||
const res = await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, file, req);
|
||||
expect(res).toEqual({ success: true });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'up.zip' }));
|
||||
});
|
||||
|
||||
it('POST /upload-restore maps a failed restore status', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: 'up.zip' } as Express.Multer.File;
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: false, status: 422, error: 'bad' }) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 422, body: { error: 'bad' } });
|
||||
});
|
||||
|
||||
it('POST /upload-restore falls back to a default name and maps unexpected errors to 500', async () => {
|
||||
const file = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).uploadRestore(user, file, req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
|
||||
const ok = { path: '/tmp/does-not-exist-xyz.zip', originalname: '' } as Express.Multer.File;
|
||||
await new BackupController(svc({ restoreFromZip: vi.fn().mockResolvedValue({ success: true }) } as Partial<BackupService>)).uploadRestore(user, ok, req);
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.upload_restore', resource: 'upload.zip' }));
|
||||
});
|
||||
|
||||
it('maps unexpected service errors to 500 (create, restore, auto-settings)', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect(await thrownAsync(() => new BackupController(svc({ createBackup: vi.fn().mockRejectedValue(new Error('disk')) } as Partial<BackupService>)).create(user, req))).toEqual({ status: 500, body: { error: 'Error creating backup' } });
|
||||
expect(await thrownAsync(() => new BackupController(svc({ restoreFromZip: vi.fn().mockRejectedValue(new Error('boom')) } as Partial<BackupService>)).restore(user, 'x.zip', req))).toEqual({ status: 500, body: { error: 'Error restoring backup' } });
|
||||
expect(thrown(() => new BackupController(svc({ getAutoSettings: vi.fn(() => { throw new Error('io'); }) } as Partial<BackupService>)).autoSettings())).toEqual({ status: 500, body: { error: 'Could not load backup settings' } });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings maps errors to 500 (with a dev-only detail)', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'development';
|
||||
const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw new Error('parse fail'); }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: 'parse fail' });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings hides the detail in production and stringifies non-Error throws', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
process.env.NODE_ENV = 'production';
|
||||
const r = thrown(() => new BackupController(svc({ updateAutoSettings: vi.fn(() => { throw 'plain string'; }) } as Partial<BackupService>)).updateAutoSettings(user, {}, req));
|
||||
expect(r.status).toBe(500);
|
||||
expect(r.body).toEqual({ error: 'Could not save auto-backup settings', detail: undefined });
|
||||
});
|
||||
|
||||
it('PUT /auto-settings tolerates a missing body', () => {
|
||||
const updateAutoSettings = vi.fn().mockReturnValue({ enabled: false, interval: 'weekly', keep_days: 30 });
|
||||
new BackupController(svc({ updateAutoSettings } as Partial<BackupService>)).updateAutoSettings(user, undefined as unknown as Record<string, unknown>, req);
|
||||
expect(updateAutoSettings).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('GET/PUT /auto-settings', () => {
|
||||
expect(new BackupController(svc({ getAutoSettings: vi.fn().mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }) } as Partial<BackupService>)).autoSettings()).toEqual({ settings: { enabled: true }, timezone: 'UTC' });
|
||||
const res = new BackupController(svc({ updateAutoSettings: vi.fn().mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }) } as Partial<BackupService>)).updateAutoSettings(user, { enabled: true }, req);
|
||||
expect(res).toEqual({ settings: { enabled: true, interval: 'daily', keep_days: 7 } });
|
||||
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'backup.auto_settings' }));
|
||||
});
|
||||
|
||||
it('DELETE /:filename 400/404, else deletes + audits', () => {
|
||||
expect(thrown(() => new BackupController(svc({ isValidBackupFilename: vi.fn().mockReturnValue(false) })).remove(user, 'x', req))).toEqual({ status: 400, body: { error: 'Invalid filename' } });
|
||||
expect(thrown(() => new BackupController(svc({ backupFileExists: vi.fn().mockReturnValue(false) })).remove(user, 'x.zip', req))).toEqual({ status: 404, body: { error: 'Backup not found' } });
|
||||
const deleteBackup = vi.fn();
|
||||
expect(new BackupController(svc({ deleteBackup } as Partial<BackupService>)).remove(user, 'x.zip', req)).toEqual({ success: true });
|
||||
expect(deleteBackup).toHaveBeenCalledWith('x.zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupService (wrapper)', () => {
|
||||
const wrapper = new RealBackupService();
|
||||
|
||||
it('forwards every call straight to the legacy backup service', async () => {
|
||||
expect(wrapper.listBackups()).toEqual([{ filename: 'svc.zip' }]);
|
||||
expect(backupSvc.listBackups).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.createBackup()).resolves.toEqual({ filename: 'svc.zip', size: 5 });
|
||||
expect(backupSvc.createBackup).toHaveBeenCalled();
|
||||
|
||||
await expect(wrapper.restoreFromZip('/tmp/a.zip')).resolves.toEqual({ success: true });
|
||||
expect(backupSvc.restoreFromZip).toHaveBeenCalledWith('/tmp/a.zip');
|
||||
|
||||
expect(wrapper.getAutoSettings()).toEqual({ settings: { enabled: false }, timezone: 'UTC' });
|
||||
expect(backupSvc.getAutoSettings).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.updateAutoSettings({ enabled: true })).toEqual({ enabled: true, interval: 'daily', keep_days: 7 });
|
||||
expect(backupSvc.updateAutoSettings).toHaveBeenCalledWith({ enabled: true });
|
||||
|
||||
wrapper.deleteBackup('svc.zip');
|
||||
expect(backupSvc.deleteBackup).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.isValidBackupFilename('svc.zip')).toBe(true);
|
||||
expect(backupSvc.isValidBackupFilename).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFilePath('svc.zip')).toBe('/data/backups/svc.zip');
|
||||
expect(backupSvc.backupFilePath).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.backupFileExists('svc.zip')).toBe(true);
|
||||
expect(backupSvc.backupFileExists).toHaveBeenCalledWith('svc.zip');
|
||||
|
||||
expect(wrapper.checkRateLimit('ip', 3, 1000)).toBe(true);
|
||||
expect(backupSvc.checkRateLimit).toHaveBeenCalledWith('ip', 3, 1000);
|
||||
});
|
||||
|
||||
it('exposes the legacy rate window', () => {
|
||||
expect(wrapper.rateWindow).toBe(backupSvc.BACKUP_RATE_WINDOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackupModule', () => {
|
||||
it('wires the controller and service together', async () => {
|
||||
const { BackupModule } = await import('../../../src/nest/backup/backup.module');
|
||||
expect(new BackupModule()).toBeInstanceOf(BackupModule);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { BudgetController } from '../../../src/nest/budget/budget.controller';
|
||||
import type { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { id: 5, user_id: 1 };
|
||||
|
||||
function makeService(overrides: Partial<BudgetService> = {}): BudgetService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue(trip),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
syncReservationPrice: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as BudgetService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
describe('BudgetController (parity with the legacy /api/trips/:tripId/budget route)', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = makeService({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new BudgetController(svc).list(user, '5'))).toEqual({
|
||||
status: 404, body: { error: 'Trip not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('GET / returns items', () => {
|
||||
const svc = makeService({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).list(user, '5')).toEqual({ items: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET /summary/per-person + /settlement delegate', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
perPersonSummary: vi.fn().mockReturnValue([{ userId: 1, owes: 10 }]),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).perPerson(user, '5')).toEqual({ summary: [{ userId: 1, owes: 10 }] });
|
||||
expect(new BudgetController(svc).settlement(user, '5')).toEqual({ transfers: [] });
|
||||
expect(settlement).toHaveBeenLastCalledWith('5', undefined, 'EUR');
|
||||
});
|
||||
|
||||
it('GET /settlement forwards the base query and the trip currency', () => {
|
||||
const settlement = vi.fn().mockReturnValue({ transfers: [] });
|
||||
const svc = makeService({
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ id: 5, user_id: 1, currency: 'USD' }),
|
||||
settlement,
|
||||
} as Partial<BudgetService>);
|
||||
new BudgetController(svc).settlement(user, '5', 'GBP');
|
||||
expect(settlement).toHaveBeenCalledWith('5', 'GBP', 'USD');
|
||||
});
|
||||
|
||||
describe('settlements ledger', () => {
|
||||
it('GET /settlements lists', () => {
|
||||
const svc = makeService({ listSettlements: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).listSettlements(user, '5')).toEqual({ settlements: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST /settlements 403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 10 }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements 400 when a field is missing', () => {
|
||||
const svc = makeService();
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
expect(thrown(() => new BudgetController(svc).createSettlement(user, '5', { to_user_id: 2, amount: 5 }))).toEqual({
|
||||
status: 400, body: { error: 'from_user_id, to_user_id and amount are required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /settlements creates and broadcasts (amount 0 is allowed)', () => {
|
||||
const createSettlement = vi.fn().mockReturnValue({ id: 3, amount: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ createSettlement, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).createSettlement(user, '5', { from_user_id: 1, to_user_id: 2, amount: 0 }, 'sock');
|
||||
expect(res).toEqual({ settlement: { id: 3, amount: 0 } });
|
||||
expect(createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 0 }, user.id);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-created', { settlement: { id: 3, amount: 0 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id 404 when missing', () => {
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).deleteSettlement(user, '5', '7'))).toEqual({
|
||||
status: 404, body: { error: 'Settlement not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /settlements/:id success broadcasts the numeric id', () => {
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ deleteSettlement: vi.fn().mockReturnValue(true), broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).deleteSettlement(user, '5', '7', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:settlement-deleted', { settlementId: 7 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('403 without budget_edit', () => {
|
||||
const svc = makeService({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new BudgetController(svc).create(user, '5', { name: 'Hotel' }))).toEqual({
|
||||
status: 403, body: { error: 'No permission' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when name missing', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).create(user, '5', {}))).toEqual({
|
||||
status: 400, body: { error: 'Name is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('creates and broadcasts', () => {
|
||||
const create = vi.fn().mockReturnValue({ id: 9, name: 'Hotel' });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ create, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).create(user, '5', { name: 'Hotel', total_price: 200 }, 'sock')).toEqual({ item: { id: 9, name: 'Hotel' } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 9, name: 'Hotel' } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when item missing', () => {
|
||||
const svc = makeService({ update: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).update(user, '5', '9', { name: 'X' }))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('syncs the reservation price when a linked item changes total_price', () => {
|
||||
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: 42, total_price: 250 });
|
||||
const syncReservationPrice = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ update, syncReservationPrice, broadcast } as Partial<BudgetService>);
|
||||
new BudgetController(svc).update(user, '5', '9', { total_price: 250 }, 'sock');
|
||||
expect(syncReservationPrice).toHaveBeenCalledWith('5', 42, 250, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, reservation_id: 42, total_price: 250 } }, 'sock');
|
||||
});
|
||||
|
||||
it('does not sync when the item has no linked reservation', () => {
|
||||
const update = vi.fn().mockReturnValue({ id: 9, reservation_id: null, total_price: 250 });
|
||||
const syncReservationPrice = vi.fn();
|
||||
const svc = makeService({ update, syncReservationPrice } as Partial<BudgetService>);
|
||||
new BudgetController(svc).update(user, '5', '9', { total_price: 250 });
|
||||
expect(syncReservationPrice).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/members', () => {
|
||||
it('400 when user_ids is not an array', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).updateMembers(user, '5', '9', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'user_ids must be an array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ updateMembers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).updateMembers(user, '5', '9', [2, 3]))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('updates members and broadcasts persons count', () => {
|
||||
const updateMembers = vi.fn().mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ updateMembers, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).updateMembers(user, '5', '9', [2], 'sock');
|
||||
expect(res).toEqual({ members: [{ user_id: 2 }], item: { persons: 1 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:members-updated', { itemId: 9, members: [{ user_id: 2 }], persons: 1 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id/payers', () => {
|
||||
it('400 when payers is not an array', () => {
|
||||
expect(thrown(() => new BudgetController(makeService()).setPayers(user, '5', '9', 'nope'))).toEqual({
|
||||
status: 400, body: { error: 'payers must be an array' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 when the item is missing', () => {
|
||||
const svc = makeService({ setPayers: vi.fn().mockReturnValue(null) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }]))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sets payers and broadcasts budget:updated', () => {
|
||||
const setPayers = vi.fn().mockReturnValue({ id: 9, payers: [{ user_id: 2, amount: 10 }] });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ setPayers, broadcast } as Partial<BudgetService>);
|
||||
const res = new BudgetController(svc).setPayers(user, '5', '9', [{ user_id: 2, amount: 10 }], 'sock');
|
||||
expect(res).toEqual({ item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } });
|
||||
expect(setPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:updated', { item: { id: 9, payers: [{ user_id: 2, amount: 10 }] } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid toggles + broadcasts normalised paid flag', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 1 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', true, 'sock')).toEqual({ member: { user_id: 2, paid: 1 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 1 }, 'sock');
|
||||
});
|
||||
|
||||
it('PUT /:id/members/:userId/paid broadcasts paid: 0 when toggled off', () => {
|
||||
const toggleMemberPaid = vi.fn().mockReturnValue({ user_id: 2, paid: 0 });
|
||||
const broadcast = vi.fn();
|
||||
const svc = makeService({ toggleMemberPaid, broadcast } as Partial<BudgetService>);
|
||||
new BudgetController(svc).toggleMemberPaid(user, '5', '9', '2', false, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:member-paid-updated', { itemId: 9, userId: 2, paid: 0 }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, success otherwise', () => {
|
||||
const missing = makeService({ remove: vi.fn().mockReturnValue(false) } as Partial<BudgetService>);
|
||||
expect(thrown(() => new BudgetController(missing).remove(user, '5', '9'))).toEqual({
|
||||
status: 404, body: { error: 'Budget item not found' },
|
||||
});
|
||||
const ok = makeService({ remove: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<BudgetService>);
|
||||
expect(new BudgetController(ok).remove(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('PUT /reorder/items + /reorder/categories broadcast budget:reordered', () => {
|
||||
const reorderItems = vi.fn(); const reorderCategories = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = makeService({ reorderItems, reorderCategories, broadcast } as Partial<BudgetService>);
|
||||
expect(new BudgetController(svc).reorderItems(user, '5', [3, 1], 'sock')).toEqual({ success: true });
|
||||
expect(reorderItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
expect(new BudgetController(svc).reorderCategories(user, '5', ['food', 'fun'], 'sock')).toEqual({ success: true });
|
||||
expect(reorderCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the data + side-effect dependencies the wrapper reaches into directly.
|
||||
const { dbMock } = vi.hoisted(() => {
|
||||
const stmt = { get: vi.fn(), all: vi.fn(() => []), run: vi.fn() };
|
||||
return { dbMock: { prepare: vi.fn(() => stmt), _stmt: stmt } };
|
||||
});
|
||||
vi.mock('../../../src/db/database', () => ({ db: dbMock, closeDb: () => {}, reinitialize: () => {} }));
|
||||
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { getRates } = vi.hoisted(() => ({ getRates: vi.fn() }));
|
||||
vi.mock('../../../src/services/exchangeRateService', () => ({ getRates }));
|
||||
|
||||
const { budget } = vi.hoisted(() => ({
|
||||
budget: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
listBudgetItems: vi.fn(),
|
||||
getPerPersonSummary: vi.fn(),
|
||||
calculateSettlement: vi.fn(),
|
||||
createBudgetItem: vi.fn(),
|
||||
updateBudgetItem: vi.fn(),
|
||||
deleteBudgetItem: vi.fn(),
|
||||
updateMembers: vi.fn(),
|
||||
toggleMemberPaid: vi.fn(),
|
||||
setItemPayers: vi.fn(),
|
||||
listSettlements: vi.fn(),
|
||||
createSettlement: vi.fn(),
|
||||
deleteSettlement: vi.fn(),
|
||||
reorderBudgetItems: vi.fn(),
|
||||
reorderBudgetCategories: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/budgetService', () => budget);
|
||||
|
||||
import { BudgetService } from '../../../src/nest/budget/budget.service';
|
||||
|
||||
function svc() {
|
||||
return new BudgetService();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('BudgetService', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
budget.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(svc().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(budget.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('canEdit forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
expect(svc().canEdit({ user_id: 1 } as never, { id: 1, role: 'user' } as never)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('canEdit marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
expect(svc().canEdit({ user_id: 2 } as never, { id: 1, role: 'user' } as never)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('budget_edit', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
svc().broadcast('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'budget:created', { item: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('list / perPersonSummary delegate', () => {
|
||||
budget.listBudgetItems.mockReturnValue([{ id: 1 }]);
|
||||
expect(svc().list('5')).toEqual([{ id: 1 }]);
|
||||
budget.getPerPersonSummary.mockReturnValue([{ userId: 1 }]);
|
||||
expect(svc().perPersonSummary('5')).toEqual([{ userId: 1 }]);
|
||||
});
|
||||
|
||||
describe('settlement', () => {
|
||||
it('upper-cases the explicit base and forwards the rates', async () => {
|
||||
getRates.mockResolvedValue({ USD: 1.1 });
|
||||
budget.calculateSettlement.mockReturnValue({ transfers: [] });
|
||||
await svc().settlement('5', 'usd', 'EUR');
|
||||
expect(getRates).toHaveBeenCalledWith('USD');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'USD', rates: { USD: 1.1 }, tripCurrency: 'EUR' });
|
||||
});
|
||||
|
||||
it('falls back to the trip currency when no base is given', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, 'gbp');
|
||||
expect(getRates).toHaveBeenCalledWith('GBP');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'GBP', rates: null, tripCurrency: 'gbp' });
|
||||
});
|
||||
|
||||
it('falls back to EUR when neither base nor trip currency is present', async () => {
|
||||
getRates.mockResolvedValue(null);
|
||||
await svc().settlement('5', undefined, '');
|
||||
expect(getRates).toHaveBeenCalledWith('EUR');
|
||||
expect(budget.calculateSettlement).toHaveBeenCalledWith('5', { base: 'EUR', rates: null, tripCurrency: '' });
|
||||
});
|
||||
});
|
||||
|
||||
it('create / update / remove / members / paid / payers delegate', () => {
|
||||
svc().create('5', { name: 'Hotel' } as never);
|
||||
expect(budget.createBudgetItem).toHaveBeenCalledWith('5', { name: 'Hotel' });
|
||||
svc().update('9', '5', { name: 'X' });
|
||||
expect(budget.updateBudgetItem).toHaveBeenCalledWith('9', '5', { name: 'X' });
|
||||
svc().remove('9', '5');
|
||||
expect(budget.deleteBudgetItem).toHaveBeenCalledWith('9', '5');
|
||||
svc().updateMembers('9', '5', [2, 3]);
|
||||
expect(budget.updateMembers).toHaveBeenCalledWith('9', '5', [2, 3]);
|
||||
svc().toggleMemberPaid('9', '5', '2', true);
|
||||
expect(budget.toggleMemberPaid).toHaveBeenCalledWith('9', '5', '2', true);
|
||||
svc().setPayers('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
expect(budget.setItemPayers).toHaveBeenCalledWith('9', '5', [{ user_id: 2, amount: 10 }]);
|
||||
});
|
||||
|
||||
it('settlement ledger + reorder delegate', () => {
|
||||
svc().listSettlements('5');
|
||||
expect(budget.listSettlements).toHaveBeenCalledWith('5');
|
||||
svc().createSettlement('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
expect(budget.createSettlement).toHaveBeenCalledWith('5', { from_user_id: 1, to_user_id: 2, amount: 10 }, 3);
|
||||
svc().deleteSettlement('7', '5');
|
||||
expect(budget.deleteSettlement).toHaveBeenCalledWith('7', '5');
|
||||
svc().reorderItems('5', [3, 1]);
|
||||
expect(budget.reorderBudgetItems).toHaveBeenCalledWith('5', [3, 1]);
|
||||
svc().reorderCategories('5', ['food', 'fun']);
|
||||
expect(budget.reorderBudgetCategories).toHaveBeenCalledWith('5', ['food', 'fun']);
|
||||
});
|
||||
|
||||
describe('syncReservationPrice', () => {
|
||||
it('returns early when the reservation is not found', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce(undefined);
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
expect(dbMock._stmt.run).not.toHaveBeenCalled();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('merges into existing metadata and broadcasts reservation:updated', () => {
|
||||
dbMock._stmt.get
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME"}' }) // lookup
|
||||
.mockReturnValueOnce({ id: 42, metadata: '{"vendor":"ACME","price":"250"}' }); // reload
|
||||
svc().syncReservationPrice('5', 42, 250, 'sock');
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ vendor: 'ACME', price: '250' });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'reservation:updated', { reservation: { id: 42, metadata: '{"vendor":"ACME","price":"250"}' } }, 'sock');
|
||||
});
|
||||
|
||||
it('starts from an empty object when the reservation has no metadata', () => {
|
||||
dbMock._stmt.get.mockReturnValueOnce({ id: 42, metadata: null }).mockReturnValueOnce({ id: 42 });
|
||||
svc().syncReservationPrice('5', 42, 99, undefined);
|
||||
const writtenMeta = JSON.parse(dbMock._stmt.run.mock.calls[0][0] as string);
|
||||
expect(writtenMeta).toEqual({ price: '99' });
|
||||
});
|
||||
|
||||
it('swallows errors so a sync failure never breaks the budget update', () => {
|
||||
dbMock.prepare.mockImplementationOnce(() => { throw new Error('db gone'); });
|
||||
expect(() => svc().syncReservationPrice('5', 42, 250, 'sock')).not.toThrow();
|
||||
expect(broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { CategoriesController } from '../../../src/nest/categories/categories.controller';
|
||||
import type { CategoriesService } from '../../../src/nest/categories/categories.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import type { Category } from '@trek/shared';
|
||||
|
||||
const admin = { id: 1, role: 'admin' } as User;
|
||||
|
||||
function makeController(svc: Partial<CategoriesService>) {
|
||||
return new CategoriesController(svc as CategoriesService);
|
||||
}
|
||||
|
||||
const cat: Category = { id: 1, name: 'Food', color: '#fff', icon: '🍔' };
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try {
|
||||
fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
describe('CategoriesController (parity with the legacy /api/categories route)', () => {
|
||||
it('GET / returns the category list wrapped in { categories }', () => {
|
||||
const list = vi.fn().mockReturnValue([cat]);
|
||||
expect(makeController({ list }).list()).toEqual({ categories: [cat] });
|
||||
});
|
||||
|
||||
describe('POST /', () => {
|
||||
it('400 when name is missing', () => {
|
||||
const create = vi.fn();
|
||||
expect(thrown(() => makeController({ create }).create(admin, undefined))).toEqual({
|
||||
status: 400, body: { error: 'Category name is required' },
|
||||
});
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates and returns { category }', () => {
|
||||
const create = vi.fn().mockReturnValue(cat);
|
||||
expect(makeController({ create }).create(admin, 'Food', '#fff', '🍔')).toEqual({ category: cat });
|
||||
expect(create).toHaveBeenCalledWith(1, 'Food', '#fff', '🍔');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /:id', () => {
|
||||
it('404 when the category does not exist', () => {
|
||||
const getById = vi.fn().mockReturnValue(undefined);
|
||||
const update = vi.fn();
|
||||
expect(thrown(() => makeController({ getById, update }).update('9', 'X'))).toEqual({
|
||||
status: 404, body: { error: 'Category not found' },
|
||||
});
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates and returns { category }', () => {
|
||||
const getById = vi.fn().mockReturnValue(cat);
|
||||
const update = vi.fn().mockReturnValue({ ...cat, name: 'Drinks' });
|
||||
expect(makeController({ getById, update }).update('1', 'Drinks')).toEqual({ category: { ...cat, name: 'Drinks' } });
|
||||
expect(update).toHaveBeenCalledWith('1', 'Drinks', undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /:id', () => {
|
||||
it('404 when the category does not exist', () => {
|
||||
const getById = vi.fn().mockReturnValue(undefined);
|
||||
const remove = vi.fn();
|
||||
expect(thrown(() => makeController({ getById, remove }).remove('9'))).toEqual({
|
||||
status: 404, body: { error: 'Category not found' },
|
||||
});
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes and returns { success: true }', () => {
|
||||
const getById = vi.fn().mockReturnValue(cat);
|
||||
const remove = vi.fn();
|
||||
expect(makeController({ getById, remove }).remove('1')).toEqual({ success: true });
|
||||
expect(remove).toHaveBeenCalledWith('1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import { CollabController } from '../../../src/nest/collab/collab.controller';
|
||||
import type { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<CollabService> = {}): CollabService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
canEdit: vi.fn().mockReturnValue(true),
|
||||
canUploadFiles: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
notifyCollab: vi.fn(),
|
||||
...o,
|
||||
} as unknown as CollabService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('CollabController (parity with the legacy /api/trips/:tripId/collab route)', () => {
|
||||
describe('notes', () => {
|
||||
it('GET 404 without access, else lists', () => {
|
||||
expect(thrown(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).listNotes(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const s = svc({ listNotes: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<CollabService>);
|
||||
expect(new CollabController(s).listNotes(user, '5')).toEqual({ notes: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST 403 without collab_edit, 400 without title, else creates + broadcasts + notifies', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) })).createNote(user, '5', { title: 'T' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new CollabController(svc()).createNote(user, '5', {}))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
const createNote = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
const s = svc({ createNote, broadcast, notifyCollab } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createNote(user, '5', { title: 'T', content: 'c' }, 'sock')).toEqual({ note: { id: 9 } });
|
||||
expect(createNote).toHaveBeenCalledWith('5', 1, { title: 'T', content: 'c', category: undefined, color: undefined, website: undefined });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:created', { note: { id: 9 } }, 'sock');
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user);
|
||||
});
|
||||
|
||||
it('PUT 404 when missing, else updates + broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ updateNote: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).updateNote(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ updateNote: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).updateNote(user, '5', '9', { title: 'b' }, 'sock')).toEqual({ note: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE 404 when missing, else success + broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteNote: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deleteNote(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ deleteNote: vi.fn().mockReturnValue(true), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteNote(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:deleted', { noteId: 9 }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('note files', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
it('403 without file_upload, 400 without file, 404 unknown note, else returns result', () => {
|
||||
expect(thrown(() => new CollabController(svc({ canUploadFiles: vi.fn().mockReturnValue(false) })).addNoteFile(user, '5', '9', file))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(thrown(() => new CollabController(svc()).addNoteFile(user, '5', '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
expect(thrown(() => new CollabController(svc({ addNoteFile: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).addNoteFile(user, '5', '9', file))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ addNoteFile: vi.fn().mockReturnValue({ file: { id: 3 } }), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).addNoteFile(user, '5', '9', file, 'sock')).toEqual({ file: { id: 3 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:note:updated', { note: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('DELETE file 404 when missing, else success', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteNoteFile: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deleteNoteFile(user, '5', '9', '3'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const s = svc({ deleteNoteFile: vi.fn().mockReturnValue(true), getFormattedNoteById: vi.fn().mockReturnValue({ id: 9 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteNoteFile(user, '5', '9', '3')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('polls', () => {
|
||||
it('POST 400 without question / <2 options, else creates', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', {}))).toEqual({ status: 400, body: { error: 'Question is required' } });
|
||||
expect(thrown(() => new CollabController(svc()).createPoll(user, '5', { question: 'q', options: ['only'] }))).toEqual({ status: 400, body: { error: 'At least 2 options are required' } });
|
||||
const s = svc({ createPoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createPoll(user, '5', { question: 'q', options: ['a', 'b'] })).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('vote maps not_found/closed/invalid_index, else broadcasts the poll', () => {
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'closed' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 0))).toEqual({ status: 400, body: { error: 'Poll is closed' } });
|
||||
expect(thrown(() => new CollabController(svc({ votePoll: vi.fn().mockReturnValue({ error: 'invalid_index' }) } as Partial<CollabService>)).votePoll(user, '5', '7', 9))).toEqual({ status: 400, body: { error: 'Invalid option index' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ votePoll: vi.fn().mockReturnValue({ poll: { id: 7 } }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).votePoll(user, '5', '7', 0, 'sock')).toEqual({ poll: { id: 7 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:poll:voted', { poll: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('close 404 when missing, else broadcasts', () => {
|
||||
expect(thrown(() => new CollabController(svc({ closePoll: vi.fn().mockReturnValue(null) } as Partial<CollabService>)).closePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ closePoll: vi.fn().mockReturnValue({ id: 7 }), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).closePoll(user, '5', '7')).toEqual({ poll: { id: 7 } });
|
||||
});
|
||||
|
||||
it('delete 404 when missing, else success', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deletePoll: vi.fn().mockReturnValue(false) } as Partial<CollabService>)).deletePoll(user, '5', '7'))).toEqual({ status: 404, body: { error: 'Poll not found' } });
|
||||
const s = svc({ deletePoll: vi.fn().mockReturnValue(true), broadcast: vi.fn() } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deletePoll(user, '5', '7')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('POST 400 over 5000 chars (before access), 400 empty, 400 reply_not_found, else creates + notifies', () => {
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: 'x'.repeat(5001) }))).toEqual({ status: 400, body: { error: 'text must be 5000 characters or less' } });
|
||||
expect(thrown(() => new CollabController(svc()).createMessage(user, '5', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial<CollabService>)).createMessage(user, '5', { text: 'hi', reply_to: 99 }))).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
const s = svc({ createMessage: vi.fn().mockReturnValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial<CollabService>);
|
||||
expect(new CollabController(s).createMessage(user, '5', { text: 'hello' }, 'sock')).toEqual({ message: { id: 3 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:created', { message: { id: 3 } }, 'sock');
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user, 'hello');
|
||||
});
|
||||
|
||||
it('react 400 without emoji, 404 unknown, else broadcasts reactions', () => {
|
||||
expect(thrown(() => new CollabController(svc()).react(user, '5', '3', ''))).toEqual({ status: 400, body: { error: 'Emoji is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial<CollabService>)).react(user, '5', '3', '👍'))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ reactMessage: vi.fn().mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).react(user, '5', '3', '👍', 'sock')).toEqual({ reactions: [{ emoji: '👍', count: 1 }] });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:reacted', { messageId: 3, reactions: [{ emoji: '👍', count: 1 }] }, 'sock');
|
||||
});
|
||||
|
||||
it('delete maps not_found (404) / not_owner (403), else success with username', () => {
|
||||
expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_found' }) } as Partial<CollabService>)).deleteMessage(user, '5', '3'))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
expect(thrown(() => new CollabController(svc({ deleteMessage: vi.fn().mockReturnValue({ error: 'not_owner' }) } as Partial<CollabService>)).deleteMessage(user, '5', '3'))).toEqual({ status: 403, body: { error: 'You can only delete your own messages' } });
|
||||
const broadcast = vi.fn();
|
||||
const s = svc({ deleteMessage: vi.fn().mockReturnValue({ username: 'bob' }), broadcast } as Partial<CollabService>);
|
||||
expect(new CollabController(s).deleteMessage(user, '5', '3', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:deleted', { messageId: 3, username: 'bob' }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link preview', () => {
|
||||
it('400 without url, maps an error result to 400, else returns the preview', async () => {
|
||||
expect(await thrownAsync(() => new CollabController(svc()).linkPreview(user, '5', undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc({ linkPreview: vi.fn().mockResolvedValue({ error: 'bad url' }) } as Partial<CollabService>)).linkPreview(user, '5', 'http://x'))).toEqual({ status: 400, body: { error: 'bad url' } });
|
||||
const s = svc({ linkPreview: vi.fn().mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s).linkPreview(user, '5', 'http://x')).toEqual({ title: 'T', description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
|
||||
it('falls back to a null preview when the service throws', async () => {
|
||||
const s = svc({ linkPreview: vi.fn().mockRejectedValue(new Error('network')) } as Partial<CollabService>);
|
||||
expect(await new CollabController(s).linkPreview(user, '5', 'http://x')).toEqual({ title: null, description: null, image: null, url: 'http://x' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ConfigController } from '../../../src/nest/config/config.controller';
|
||||
import { DEFAULT_LANGUAGE } from '../../../src/config';
|
||||
|
||||
describe('ConfigController (parity with the legacy /api/config route)', () => {
|
||||
it('returns the server default language, like the legacy public route', () => {
|
||||
expect(new ConfigController().getConfig()).toEqual({ defaultLanguage: DEFAULT_LANGUAGE });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* DatabaseService — the shared better-sqlite3 provider (F3). Exercises every
|
||||
* helper against the real connection so the typed query surface is covered.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
describe('DatabaseService (typed query helpers)', () => {
|
||||
const svc = new DatabaseService();
|
||||
|
||||
it('exposes the shared connection', () => {
|
||||
expect(typeof svc.connection.prepare).toBe('function');
|
||||
});
|
||||
|
||||
it('prepare + get + all return rows from the live connection', () => {
|
||||
expect(svc.prepare('SELECT 1 AS one').get()).toEqual({ one: 1 });
|
||||
expect(svc.get('SELECT 2 AS two')).toEqual({ two: 2 });
|
||||
expect(svc.all('SELECT 3 AS three')).toEqual([{ three: 3 }]);
|
||||
});
|
||||
|
||||
it('run + transaction operate on a scratch table', () => {
|
||||
svc.run('CREATE TEMP TABLE IF NOT EXISTS _dbsvc_test (n INTEGER)');
|
||||
svc.run('DELETE FROM _dbsvc_test');
|
||||
|
||||
const info = svc.run('INSERT INTO _dbsvc_test (n) VALUES (?)', 41);
|
||||
expect(info.changes).toBe(1);
|
||||
|
||||
const total = svc.transaction((conn) => {
|
||||
conn.prepare('INSERT INTO _dbsvc_test (n) VALUES (?)').run(1);
|
||||
return conn.prepare('SELECT SUM(n) AS s FROM _dbsvc_test').get() as { s: number };
|
||||
});
|
||||
expect(total.s).toBe(42);
|
||||
|
||||
svc.run('DROP TABLE _dbsvc_test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { DaysController } from '../../../src/nest/days/days.controller';
|
||||
import { DayNotesController } from '../../../src/nest/days/day-notes.controller';
|
||||
import { DayReorderError } from '../../../src/services/dayService';
|
||||
import type { DaysService } from '../../../src/nest/days/days.service';
|
||||
import type { DayNotesService } from '../../../src/nest/days/day-notes.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
const trip = { user_id: 1 };
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
function daysSvc(o: Partial<DaysService> = {}): DaysService {
|
||||
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DaysService;
|
||||
}
|
||||
function notesSvc(o: Partial<DayNotesService> = {}): DayNotesService {
|
||||
return { verifyTripAccess: vi.fn().mockReturnValue(trip), canEdit: vi.fn().mockReturnValue(true), broadcast: vi.fn(), ...o } as unknown as DayNotesService;
|
||||
}
|
||||
|
||||
describe('DaysController (parity with the legacy /api/trips/:tripId/days route)', () => {
|
||||
it('404 when trip not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new DaysController(svc).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('GET / returns the list service result verbatim (the { days } envelope)', () => {
|
||||
const svc = daysSvc({ list: vi.fn().mockReturnValue({ days: [{ id: 1 }] }) } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).list(user, '5')).toEqual({ days: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('POST / 403 without day_edit, then creates + broadcasts', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
const create = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
|
||||
expect(new DaysController(daysSvc({ create, broadcast } as Partial<DaysService>)).create(user, '5', { date: '2026-07-01' }, 'sock')).toEqual({ day: { id: 9 } });
|
||||
expect(create).toHaveBeenCalledWith('5', '2026-07-01', undefined);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:created', { day: { id: 9 } }, 'sock');
|
||||
});
|
||||
|
||||
it('POST / 404 when the trip is not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(null) });
|
||||
expect(thrown(() => new DaysController(svc).create(user, '5', {}))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('POST / with a position inserts + broadcasts day:reordered', () => {
|
||||
const insert = vi.fn().mockReturnValue({ id: 12 }); const create = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ insert, create, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).create(user, '5', { position: 0 }, 'sock')).toEqual({ day: { id: 12 } });
|
||||
expect(insert).toHaveBeenCalledWith('5', 0);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { day: { id: 12 } }, 'sock');
|
||||
});
|
||||
|
||||
describe('PUT /reorder', () => {
|
||||
it('404 when the trip is not accessible', () => {
|
||||
const svc = daysSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
|
||||
it('403 without day_edit', () => {
|
||||
const svc = daysSvc({ canEdit: vi.fn().mockReturnValue(false) });
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('400 when orderedIds is missing', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', {}))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('400 when orderedIds is not an array', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc()).reorder(user, '5', { orderedIds: 'nope' as never }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array' } });
|
||||
});
|
||||
|
||||
it('maps a DayReorderError to 400 with its message', () => {
|
||||
const reorder = vi.fn(() => { throw new DayReorderError('orderedIds must be a permutation of the trip day ids.'); });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(thrown(() => new DaysController(svc).reorder(user, '5', { orderedIds: [9] }))).toEqual({
|
||||
status: 400, body: { error: 'orderedIds must be a permutation of the trip day ids.' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rethrows a non-DayReorderError unchanged', () => {
|
||||
const boom = new Error('db is down');
|
||||
const reorder = vi.fn(() => { throw boom; });
|
||||
const svc = daysSvc({ reorder } as Partial<DaysService>);
|
||||
expect(() => new DaysController(svc).reorder(user, '5', { orderedIds: [1, 2] })).toThrow(boom);
|
||||
});
|
||||
|
||||
it('reorders and broadcasts day:reordered', () => {
|
||||
const reorder = vi.fn(); const broadcast = vi.fn();
|
||||
const svc = daysSvc({ reorder, broadcast } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).reorder(user, '5', { orderedIds: [2, 1] }, 'sock')).toEqual({ success: true });
|
||||
expect(reorder).toHaveBeenCalledWith('5', [2, 1]);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'day:reordered', { orderedIds: [2, 1] }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id 404 when the day is missing, else updates', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const update = vi.fn().mockReturnValue({ id: 9, title: 'T' });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), update } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).update(user, '5', '9', { title: 'T' })).toEqual({ day: { id: 9, title: 'T' } });
|
||||
});
|
||||
|
||||
it('DELETE /:id 404 when missing, else success', () => {
|
||||
expect(thrown(() => new DaysController(daysSvc({ getDay: vi.fn().mockReturnValue(undefined) } as Partial<DaysService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
const svc = daysSvc({ getDay: vi.fn().mockReturnValue({ id: 9 }), remove: vi.fn() } as Partial<DaysService>);
|
||||
expect(new DaysController(svc).remove(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DayNotesController (parity with the legacy /api/.../days/:dayId/notes route)', () => {
|
||||
it('400 on an over-long text BEFORE the trip-access check (middleware order)', () => {
|
||||
const verifyTripAccess = vi.fn().mockReturnValue(undefined); // would 404 if reached
|
||||
const svc = notesSvc({ verifyTripAccess });
|
||||
expect(thrown(() => new DayNotesController(svc).create(user, '5', '3', { text: 'x'.repeat(501) }))).toEqual({
|
||||
status: 400, body: { error: 'text must be 500 characters or less' },
|
||||
});
|
||||
expect(verifyTripAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 on an over-long time', () => {
|
||||
expect(thrown(() => new DayNotesController(notesSvc()).create(user, '5', '3', { text: 'ok', time: 'y'.repeat(151) }))).toEqual({
|
||||
status: 400, body: { error: 'time must be 150 characters or less' },
|
||||
});
|
||||
});
|
||||
|
||||
it('404 trip, 403 permission, 404 day, 400 empty text, then creates', () => {
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ canEdit: vi.fn().mockReturnValue(false) })).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(false) } as Partial<DayNotesService>)).create(user, '5', '3', { text: 'ok' }))).toEqual({ status: 404, body: { error: 'Day not found' } });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ dayExists: vi.fn().mockReturnValue(true) } as Partial<DayNotesService>)).create(user, '5', '3', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Text required' } });
|
||||
const create = vi.fn().mockReturnValue({ id: 7 }); const broadcast = vi.fn();
|
||||
const svc = notesSvc({ dayExists: vi.fn().mockReturnValue(true), create, broadcast } as Partial<DayNotesService>);
|
||||
expect(new DayNotesController(svc).create(user, '5', '3', { text: 'Lunch', time: '12:00' }, 'sock')).toEqual({ note: { id: 7 } });
|
||||
expect(create).toHaveBeenCalledWith('3', '5', 'Lunch', '12:00', undefined, undefined);
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:created', { dayId: 3, note: { id: 7 } }, 'sock');
|
||||
});
|
||||
|
||||
it('GET / returns notes; PUT/DELETE 404 when the note is missing', () => {
|
||||
const svc = notesSvc({ list: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<DayNotesService>);
|
||||
expect(new DayNotesController(svc).list(user, '5', '3')).toEqual({ notes: [{ id: 1 }] });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial<DayNotesService>)).update(user, '5', '3', '9', { text: 'x' }))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
expect(thrown(() => new DayNotesController(notesSvc({ getNote: vi.fn().mockReturnValue(undefined) } as Partial<DayNotesService>)).remove(user, '5', '3', '9'))).toEqual({ status: 404, body: { error: 'Note not found' } });
|
||||
});
|
||||
|
||||
it('PUT/DELETE update + delete a note with broadcasts', () => {
|
||||
const update = vi.fn().mockReturnValue({ id: 9 }); const broadcast = vi.fn();
|
||||
const u = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), update, broadcast } as Partial<DayNotesService>);
|
||||
expect(new DayNotesController(u).update(user, '5', '3', '9', { text: 'x' }, 'sock')).toEqual({ note: { id: 9 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'dayNote:updated', { dayId: 3, note: { id: 9 } }, 'sock');
|
||||
const remove = vi.fn(); const b2 = vi.fn();
|
||||
const d = notesSvc({ getNote: vi.fn().mockReturnValue({ id: 9 }), remove, broadcast: b2 } as Partial<DayNotesService>);
|
||||
expect(new DayNotesController(d).remove(user, '5', '3', '9', 'sock')).toEqual({ success: true });
|
||||
expect(b2).toHaveBeenCalledWith('5', 'dayNote:deleted', { noteId: 9, dayId: 3 }, 'sock');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import { MulterError } from 'multer';
|
||||
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
|
||||
|
||||
function mockHost() {
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
|
||||
const host = { switchToHttp: () => ({ getResponse: () => res }) } as never;
|
||||
return { res, host };
|
||||
}
|
||||
|
||||
describe('TrekExceptionFilter', () => {
|
||||
const filter = new TrekExceptionFilter();
|
||||
|
||||
it('passes through { error, code } bodies (auth guards) unchanged', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ error: 'Access token required', code: 'AUTH_REQUIRED' }, 401), host);
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||
});
|
||||
|
||||
it('normalises a string HttpException to { error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException('Bad thing', 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad thing' });
|
||||
});
|
||||
|
||||
it('maps unknown errors to 500 { error: Internal server error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new Error('boom'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('maps a multer LIMIT_FILE_SIZE error to 413 with the multer message', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new MulterError('LIMIT_FILE_SIZE', 'avatar'), host);
|
||||
expect(res.status).toHaveBeenCalledWith(413);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'File too large' });
|
||||
});
|
||||
|
||||
it('maps any other multer error to 400 with the multer message', () => {
|
||||
const { res, host } = mockHost();
|
||||
const err = new MulterError('LIMIT_UNEXPECTED_FILE', 'avatar');
|
||||
filter.catch(err, host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: err.message });
|
||||
});
|
||||
|
||||
it('normalises a Nest-shaped { statusCode, message, error } body to { error }', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400, message: 'Validation failed', error: 'Bad Request' }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Validation failed' });
|
||||
});
|
||||
|
||||
it('joins an array message into a single string', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ message: ['too short', 'required'] }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'too short, required' });
|
||||
});
|
||||
|
||||
it('falls back to obj.error when an object body has no message', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400, error: 'Bad Request' }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Bad Request' });
|
||||
});
|
||||
|
||||
it("uses 'Error' when an object body carries neither message nor error", () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ statusCode: 400 }, 400), host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Error' });
|
||||
});
|
||||
|
||||
it('hides 5xx object-body details behind Internal server error', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException({ message: 'secret stack detail' }, 503), host);
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('maps a plain error with statusCode to that status (4xx exposes message)', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ statusCode: 400, message: 'Only images are allowed' }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Only images are allowed' });
|
||||
});
|
||||
|
||||
it('honours a plain error status field when statusCode is absent', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ status: 404, message: 'Not here' }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Not here' });
|
||||
});
|
||||
|
||||
it("uses 'Error' for a 4xx plain error with no message", () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch({ statusCode: 422 }, host);
|
||||
expect(res.status).toHaveBeenCalledWith(422);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Error' });
|
||||
});
|
||||
|
||||
it('hides a 5xx string-body HttpException behind Internal server error', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(new HttpException('database exploded', 500), host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
it('treats a null exception as a 500', () => {
|
||||
const { res, host } = mockHost();
|
||||
filter.catch(null, host);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
vi.mock('../../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
|
||||
import { FilesController } from '../../../src/nest/files/files.controller';
|
||||
import { FilesDownloadController } from '../../../src/nest/files/files-download.controller';
|
||||
import { PhotosController } from '../../../src/nest/photos/photos.controller';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { PhotosService } from '../../../src/nest/photos/photos.service';
|
||||
import { isDemoEmail } from '../../../src/services/demo';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function fsvc(o: Partial<FilesService> = {}): FilesService {
|
||||
return {
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
can: vi.fn().mockReturnValue(true),
|
||||
broadcast: vi.fn(),
|
||||
...o,
|
||||
} as unknown as FilesService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => { delete process.env.DEMO_MODE; });
|
||||
|
||||
describe('FilesController (parity with the legacy /api/trips/:tripId/files route)', () => {
|
||||
it('GET / 404 without access, else lists with the trash flag', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).list(user, '5'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
const listFiles = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
expect(new FilesController(fsvc({ listFiles } as Partial<FilesService>)).list(user, '5', 'true')).toEqual({ files: [{ id: 1 }] });
|
||||
expect(listFiles).toHaveBeenCalledWith('5', true);
|
||||
});
|
||||
|
||||
describe('POST / (upload)', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
it('403 in demo mode for a demo email', () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockReturnValue(true);
|
||||
expect(thrown(() => new FilesController(fsvc()).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' } });
|
||||
});
|
||||
it('403 without file_upload, 400 without a file, else creates + broadcasts', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).upload(user, '5', file, {}))).toEqual({ status: 403, body: { error: 'No permission to upload files' } });
|
||||
expect(thrown(() => new FilesController(fsvc()).upload(user, '5', undefined, {}))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
const createFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const broadcast = vi.fn();
|
||||
const s = fsvc({ createFile, broadcast } as Partial<FilesService>);
|
||||
expect(new FilesController(s).upload(user, '5', file, { description: 'd' }, 'sock')).toEqual({ file: { id: 9 } });
|
||||
expect(createFile).toHaveBeenCalledWith('5', file, 1, { place_id: undefined, description: 'd', reservation_id: undefined });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 9 } }, 'sock');
|
||||
});
|
||||
});
|
||||
|
||||
it('PUT /:id 403 without file_edit, 404 unknown, else updates + broadcasts', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).update(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission to edit files' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).update(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const updateFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, description: 'x' }), updateFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).update(user, '5', '9', { description: 'new' })).toEqual({ file: { id: 9 } });
|
||||
});
|
||||
|
||||
it('PATCH /:id/star 403/404, else toggles', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).star(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).star(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const toggleStarred = vi.fn().mockReturnValue({ id: 9, starred: 1 });
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9, starred: 0 }), toggleStarred, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).star(user, '5', '9')).toEqual({ file: { id: 9, starred: 1 } });
|
||||
expect(toggleStarred).toHaveBeenCalledWith('9', 0);
|
||||
});
|
||||
|
||||
it('DELETE /:id soft-delete 403/404, else success', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).remove(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission to delete files' } });
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).remove(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const softDeleteFile = vi.fn();
|
||||
const broadcast = vi.fn();
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), softDeleteFile, broadcast } as Partial<FilesService>);
|
||||
expect(new FilesController(s).remove(user, '5', '9', 'sock')).toEqual({ success: true });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:deleted', { fileId: 9 }, 'sock');
|
||||
});
|
||||
|
||||
it('POST /:id/restore 404 not in trash, else restores', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).restore(user, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found in trash' } });
|
||||
const restoreFile = vi.fn().mockReturnValue({ id: 9 });
|
||||
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), restoreFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(new FilesController(s).restore(user, '5', '9')).toEqual({ file: { id: 9 } });
|
||||
});
|
||||
|
||||
it('DELETE /:id/permanent 404 not in trash, else deletes', async () => {
|
||||
await expect(new FilesController(fsvc({ getDeletedFile: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).permanent(user, '5', '9')).rejects.toBeInstanceOf(HttpException);
|
||||
const permanentDeleteFile = vi.fn().mockResolvedValue(undefined);
|
||||
const s = fsvc({ getDeletedFile: vi.fn().mockReturnValue({ id: 9 }), permanentDeleteFile, broadcast: vi.fn() } as Partial<FilesService>);
|
||||
expect(await new FilesController(s).permanent(user, '5', '9')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('DELETE /trash/empty 403, else returns the count', async () => {
|
||||
await expect(new FilesController(fsvc({ can: vi.fn().mockReturnValue(false) })).emptyTrash(user, '5')).rejects.toBeInstanceOf(HttpException);
|
||||
const s = fsvc({ emptyTrash: vi.fn().mockResolvedValue(3) } as Partial<FilesService>);
|
||||
expect(await new FilesController(s).emptyTrash(user, '5')).toEqual({ success: true, deleted: 3 });
|
||||
});
|
||||
|
||||
it('POST /:id/link 404 unknown file, else links', () => {
|
||||
expect(thrown(() => new FilesController(fsvc({ getFileById: vi.fn().mockReturnValue(undefined) } as Partial<FilesService>)).link(user, '5', '9', {}))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
const createFileLink = vi.fn().mockReturnValue([{ id: 1 }]);
|
||||
const s = fsvc({ getFileById: vi.fn().mockReturnValue({ id: 9 }), createFileLink } as Partial<FilesService>);
|
||||
expect(new FilesController(s).link(user, '5', '9', { reservation_id: 2 })).toEqual({ success: true, links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('DELETE /:id/link/:linkId removes the link; GET /:id/links lists', () => {
|
||||
const deleteFileLink = vi.fn();
|
||||
expect(new FilesController(fsvc({ deleteFileLink } as Partial<FilesService>)).unlink(user, '5', '9', '3')).toEqual({ success: true });
|
||||
expect(deleteFileLink).toHaveBeenCalledWith('3', '9');
|
||||
const s = fsvc({ getFileLinks: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<FilesService>);
|
||||
expect(new FilesController(s).links(user, '5', '9')).toEqual({ links: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('the trash + link routes all reject without file_delete / file_edit', async () => {
|
||||
const denied = () => fsvc({ can: vi.fn().mockReturnValue(false) });
|
||||
await expect(new FilesController(denied()).permanent(user, '5', '9')).rejects.toMatchObject({ status: 403 });
|
||||
expect(thrown(() => new FilesController(denied()).restore(user, '5', '9'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(denied()).link(user, '5', '9', {}))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
expect(thrown(() => new FilesController(denied()).unlink(user, '5', '9', '3'))).toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('GET /:id/links 404 without trip access', () => {
|
||||
const s = fsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) });
|
||||
expect(thrown(() => new FilesController(s).links(user, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilesDownloadController', () => {
|
||||
function dsvc(o: Partial<FilesService> = {}): FilesService {
|
||||
return {
|
||||
authenticateDownload: vi.fn().mockReturnValue({ userId: 1 }),
|
||||
verifyTripAccess: vi.fn().mockReturnValue({ user_id: 1 }),
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'x.pdf', original_name: 'x.pdf' }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: 'C:/nope/x.pdf', safe: true }),
|
||||
...o,
|
||||
} as unknown as FilesService;
|
||||
}
|
||||
const req = { headers: {}, query: {} } as Request;
|
||||
const res = { setHeader: vi.fn(), sendFile: vi.fn() } as unknown as Response;
|
||||
|
||||
it('maps the auth error from authenticateDownload', () => {
|
||||
const s = dsvc({ authenticateDownload: vi.fn().mockReturnValue({ error: 'Authentication required', status: 401 }) });
|
||||
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 401, body: { error: 'Authentication required' } });
|
||||
});
|
||||
it('404 without trip access, 404 unknown file, 403 on an unsafe path', () => {
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ verifyTripAccess: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ getFileById: vi.fn().mockReturnValue(undefined) })).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
expect(thrown(() => new FilesDownloadController(dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: '/x', safe: false }) })).download(req, res, '5', '9'))).toEqual({ status: 403, body: { error: 'Forbidden' } });
|
||||
});
|
||||
|
||||
it('404 when the safe path is gone from disk', () => {
|
||||
const missing = path.join(os.tmpdir(), `trek-no-such-${Date.now()}.pdf`);
|
||||
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: missing, safe: true }) });
|
||||
expect(thrown(() => new FilesDownloadController(s).download(req, res, '5', '9'))).toEqual({ status: 404, body: { error: 'File not found' } });
|
||||
});
|
||||
|
||||
it('streams a regular file via sendFile with an explicit root', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-dl-${Date.now()}.pdf`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const sendFile = vi.fn();
|
||||
const localRes = { setHeader: vi.fn(), sendFile } as unknown as Response;
|
||||
const s = dsvc({ resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }) });
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(sendFile).toHaveBeenCalledWith(path.basename(real), { root: path.dirname(real) });
|
||||
expect(localRes.setHeader).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
|
||||
it('serves a .pkpass inline with the Wallet MIME type and the original name', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const setHeader = vi.fn();
|
||||
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
||||
const s = dsvc({
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: 'BoardingPass.pkpass' }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
||||
});
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Type', 'application/vnd.apple.pkpass');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', 'inline; filename="BoardingPass.pkpass"');
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the resolved basename when a .pkpass has no original name', () => {
|
||||
const real = path.join(os.tmpdir(), `trek-pass-${Date.now()}.pkpass`);
|
||||
fs.writeFileSync(real, 'x');
|
||||
try {
|
||||
const setHeader = vi.fn();
|
||||
const localRes = { setHeader, sendFile: vi.fn() } as unknown as Response;
|
||||
const s = dsvc({
|
||||
getFileById: vi.fn().mockReturnValue({ filename: 'pass.pkpass', original_name: null }),
|
||||
resolveFilePath: vi.fn().mockReturnValue({ resolved: real, safe: true }),
|
||||
});
|
||||
new FilesDownloadController(s).download(req, localRes, '5', '9');
|
||||
expect(setHeader).toHaveBeenCalledWith('Content-Disposition', `inline; filename="${path.basename(real)}"`);
|
||||
} finally {
|
||||
fs.unlinkSync(real);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhotosController', () => {
|
||||
const user2 = { id: 1 } as User;
|
||||
function psvc(o: Partial<PhotosService> = {}): PhotosService {
|
||||
return { canAccess: vi.fn().mockReturnValue(true), stream: vi.fn().mockResolvedValue(undefined), info: vi.fn(), ...o } as unknown as PhotosService;
|
||||
}
|
||||
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
|
||||
it('400 on a non-finite id, 403 without access', async () => {
|
||||
await expect(new PhotosController(psvc()).thumbnail(user2, 'abc', res)).rejects.toMatchObject({ status: 400 });
|
||||
await expect(new PhotosController(psvc({ canAccess: vi.fn().mockReturnValue(false) })).original(user2, '5', res)).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it('streams thumbnail/original', async () => {
|
||||
const stream = vi.fn().mockResolvedValue(undefined);
|
||||
const c = new PhotosController(psvc({ stream }));
|
||||
await c.thumbnail(user2, '5', res);
|
||||
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'thumbnail');
|
||||
await c.original(user2, '5', res);
|
||||
expect(stream).toHaveBeenCalledWith(res, 1, 5, 'original');
|
||||
});
|
||||
|
||||
it('info writes the data, maps a service error', async () => {
|
||||
const okRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ data: { id: '5' } }) })).info(user2, '5', okRes);
|
||||
expect(okRes.json).toHaveBeenCalledWith({ id: '5' });
|
||||
const errRes = { status: vi.fn().mockReturnThis(), json: vi.fn() } as unknown as Response;
|
||||
await new PhotosController(psvc({ info: vi.fn().mockResolvedValue({ error: { status: 404, message: 'Photo not found' } }) })).info(user2, '5', errRes);
|
||||
expect(errRes.status).toHaveBeenCalledWith(404);
|
||||
expect(errRes.json).toHaveBeenCalledWith({ error: 'Photo not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { Request } from 'express';
|
||||
|
||||
// Mock the side-effect dependencies the wrapper reaches into directly.
|
||||
const { broadcast } = vi.hoisted(() => ({ broadcast: vi.fn() }));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast }));
|
||||
|
||||
const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn(() => true) }));
|
||||
vi.mock('../../../src/services/permissions', () => ({ checkPermission }));
|
||||
|
||||
const { svc } = vi.hoisted(() => ({
|
||||
svc: {
|
||||
verifyTripAccess: vi.fn(),
|
||||
authenticateDownload: vi.fn(),
|
||||
resolveFilePath: vi.fn(),
|
||||
listFiles: vi.fn(),
|
||||
getFileById: vi.fn(),
|
||||
getDeletedFile: vi.fn(),
|
||||
createFile: vi.fn(),
|
||||
updateFile: vi.fn(),
|
||||
toggleStarred: vi.fn(),
|
||||
softDeleteFile: vi.fn(),
|
||||
restoreFile: vi.fn(),
|
||||
permanentDeleteFile: vi.fn(),
|
||||
emptyTrash: vi.fn(),
|
||||
createFileLink: vi.fn(),
|
||||
deleteFileLink: vi.fn(),
|
||||
getFileLinks: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/fileService', () => svc);
|
||||
|
||||
import { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
function service() {
|
||||
return new FilesService();
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('FilesService (thin wrapper around the legacy fileService)', () => {
|
||||
it('verifyTripAccess delegates to the legacy service', () => {
|
||||
svc.verifyTripAccess.mockReturnValue({ id: 5, user_id: 2 });
|
||||
expect(service().verifyTripAccess('5', 2)).toEqual({ id: 5, user_id: 2 });
|
||||
expect(svc.verifyTripAccess).toHaveBeenCalledWith('5', 2);
|
||||
});
|
||||
|
||||
it('can forwards the ownership flag when the user owns the trip', () => {
|
||||
checkPermission.mockReturnValue(true);
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(service().can('file_edit', { user_id: 1 } as never, user)).toBe(true);
|
||||
expect(checkPermission).toHaveBeenCalledWith('file_edit', 'user', 1, 1, false);
|
||||
});
|
||||
|
||||
it('can marks the user as a guest when they do not own the trip', () => {
|
||||
checkPermission.mockReturnValue(false);
|
||||
const user = { id: 1, role: 'user' } as User;
|
||||
expect(service().can('file_upload', { user_id: 2 } as never, user)).toBe(false);
|
||||
expect(checkPermission).toHaveBeenCalledWith('file_upload', 'user', 2, 1, true);
|
||||
});
|
||||
|
||||
it('broadcast forwards to the websocket helper', () => {
|
||||
service().broadcast('5', 'file:created', { file: { id: 1 } }, 'sock');
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'file:created', { file: { id: 1 } }, 'sock');
|
||||
});
|
||||
|
||||
it('authenticateDownload / resolveFilePath delegate', () => {
|
||||
const req = { headers: {} } as Request;
|
||||
svc.authenticateDownload.mockReturnValue({ userId: 7 });
|
||||
expect(service().authenticateDownload(req)).toEqual({ userId: 7 });
|
||||
expect(svc.authenticateDownload).toHaveBeenCalledWith(req);
|
||||
|
||||
svc.resolveFilePath.mockReturnValue({ resolved: '/a/b.pdf', safe: true });
|
||||
expect(service().resolveFilePath('b.pdf')).toEqual({ resolved: '/a/b.pdf', safe: true });
|
||||
expect(svc.resolveFilePath).toHaveBeenCalledWith('b.pdf');
|
||||
});
|
||||
|
||||
it('the read helpers delegate', () => {
|
||||
svc.listFiles.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().listFiles('5', true)).toEqual([{ id: 1 }]);
|
||||
expect(svc.listFiles).toHaveBeenCalledWith('5', true);
|
||||
|
||||
svc.getFileById.mockReturnValue({ id: 9 });
|
||||
expect(service().getFileById('9', '5')).toEqual({ id: 9 });
|
||||
expect(svc.getFileById).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
svc.getDeletedFile.mockReturnValue({ id: 9 });
|
||||
expect(service().getDeletedFile('9', '5')).toEqual({ id: 9 });
|
||||
expect(svc.getDeletedFile).toHaveBeenCalledWith('9', '5');
|
||||
|
||||
svc.getFileLinks.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().getFileLinks('9')).toEqual([{ id: 1 }]);
|
||||
expect(svc.getFileLinks).toHaveBeenCalledWith('9');
|
||||
});
|
||||
|
||||
it('the mutating helpers delegate', () => {
|
||||
const file = { filename: 'a.pdf' } as Express.Multer.File;
|
||||
svc.createFile.mockReturnValue({ id: 9 });
|
||||
expect(service().createFile('5', file, 1, { description: 'd' })).toEqual({ id: 9 });
|
||||
expect(svc.createFile).toHaveBeenCalledWith('5', file, 1, { description: 'd' });
|
||||
|
||||
svc.updateFile.mockReturnValue({ id: 9 });
|
||||
const current = { id: 9 } as never;
|
||||
expect(service().updateFile('9', current, { description: 'x' })).toEqual({ id: 9 });
|
||||
expect(svc.updateFile).toHaveBeenCalledWith('9', current, { description: 'x' });
|
||||
|
||||
svc.toggleStarred.mockReturnValue({ id: 9, starred: 1 });
|
||||
expect(service().toggleStarred('9', 0)).toEqual({ id: 9, starred: 1 });
|
||||
expect(svc.toggleStarred).toHaveBeenCalledWith('9', 0);
|
||||
|
||||
service().softDeleteFile('9');
|
||||
expect(svc.softDeleteFile).toHaveBeenCalledWith('9');
|
||||
|
||||
svc.restoreFile.mockReturnValue({ id: 9 });
|
||||
expect(service().restoreFile('9')).toEqual({ id: 9 });
|
||||
expect(svc.restoreFile).toHaveBeenCalledWith('9');
|
||||
|
||||
const trashed = { id: 9 } as never;
|
||||
service().permanentDeleteFile(trashed);
|
||||
expect(svc.permanentDeleteFile).toHaveBeenCalledWith(trashed);
|
||||
|
||||
svc.emptyTrash.mockReturnValue(3);
|
||||
expect(service().emptyTrash('5')).toBe(3);
|
||||
expect(svc.emptyTrash).toHaveBeenCalledWith('5');
|
||||
|
||||
svc.createFileLink.mockReturnValue([{ id: 1 }]);
|
||||
expect(service().createFileLink('9', { reservation_id: 2 })).toEqual([{ id: 1 }]);
|
||||
expect(svc.createFileLink).toHaveBeenCalledWith('9', { reservation_id: 2 });
|
||||
|
||||
service().deleteFileLink('3', '9');
|
||||
expect(svc.deleteFileLink).toHaveBeenCalledWith('3', '9');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { HealthService } from '../../../src/nest/health/health.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function makeService(overrides: Partial<HealthService> = {}): HealthService {
|
||||
return {
|
||||
info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 0 }),
|
||||
...overrides,
|
||||
} as unknown as HealthService;
|
||||
}
|
||||
|
||||
describe('HealthController (foundation smoke endpoints under /api/_nest)', () => {
|
||||
it('GET /health merges ok:true with the service info', () => {
|
||||
const svc = makeService({
|
||||
info: vi.fn().mockReturnValue({ runtime: 'nestjs', diInjected: true, userCount: 7 }),
|
||||
});
|
||||
expect(new HealthController(svc).getHealth()).toEqual({
|
||||
ok: true,
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it('GET /me returns the authenticated user as-is', () => {
|
||||
const svc = makeService();
|
||||
expect(new HealthController(svc).me(user)).toBe(user);
|
||||
});
|
||||
|
||||
it('POST /echo wraps the validated body', () => {
|
||||
const svc = makeService();
|
||||
expect(new HealthController(svc).echo({ name: 'Maurice' })).toEqual({
|
||||
youSent: { name: 'Maurice' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('HealthService.info (shared SQLite connection proof)', () => {
|
||||
function makeDb(get: () => unknown): DatabaseService {
|
||||
return { get: vi.fn(get) } as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
it('returns the real user count when the row resolves', () => {
|
||||
const service = new HealthService(makeDb(() => ({ n: 42 })));
|
||||
expect(service.info()).toEqual({
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to null when the row is undefined', () => {
|
||||
const service = new HealthService(makeDb(() => undefined));
|
||||
expect(service.info().userCount).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to null when the count column is null', () => {
|
||||
const service = new HealthService(makeDb(() => ({ n: null })));
|
||||
expect(service.info().userCount).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { HealthController } from '../../../src/nest/health/health.controller';
|
||||
import { HealthService } from '../../../src/nest/health/health.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
describe('Nest dependency injection (vitest + swc)', () => {
|
||||
it('injects HealthService + DatabaseService into HealthController by type', async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
{ provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const controller = moduleRef.get(HealthController);
|
||||
expect(controller.getHealth()).toEqual({
|
||||
ok: true,
|
||||
runtime: 'nestjs',
|
||||
diInjected: true,
|
||||
userCount: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { CallHandler, ExecutionContext } from '@nestjs/common';
|
||||
import { of, lastValueFrom } from 'rxjs';
|
||||
import { IdempotencyInterceptor } from '../../../src/nest/common/idempotency.interceptor';
|
||||
import type { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
type ReqShape = {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
path?: string;
|
||||
user?: { id: number };
|
||||
};
|
||||
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
status: vi.fn((code: number) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
}),
|
||||
json: vi.fn((body: unknown) => body),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
function ctx(req: ReqShape, res: ReturnType<typeof makeRes>): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => req, getResponse: () => res }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
function handler(result: unknown): CallHandler & { handle: ReturnType<typeof vi.fn> } {
|
||||
return { handle: vi.fn(() => of(result)) };
|
||||
}
|
||||
|
||||
function makeDb(overrides: Partial<DatabaseService> = {}): DatabaseService {
|
||||
return { get: vi.fn(), run: vi.fn(), ...overrides } as unknown as DatabaseService;
|
||||
}
|
||||
|
||||
describe('IdempotencyInterceptor (parity with the legacy applyIdempotency middleware)', () => {
|
||||
it('passes a GET through without touching the store', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('weather');
|
||||
const out = await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'GET', headers: {} }, makeRes()), h),
|
||||
);
|
||||
expect(out).toBe('weather');
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes a mutating request without a key through', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: {}, user: { id: 1 } }, makeRes()), h),
|
||||
);
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes through when there is no authenticated user', async () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' } }, makeRes()), h),
|
||||
);
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
expect(db.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an over-long key with the exact legacy 400 body', () => {
|
||||
const db = makeDb();
|
||||
const h = handler('done');
|
||||
const run = () =>
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'x'.repeat(129) }, user: { id: 1 } }, makeRes()),
|
||||
h,
|
||||
);
|
||||
expect(run).toThrow(HttpException);
|
||||
try {
|
||||
run();
|
||||
} catch (err) {
|
||||
const e = err as HttpException;
|
||||
expect(e.getStatus()).toBe(400);
|
||||
expect(e.getResponse()).toEqual({ error: 'X-Idempotency-Key exceeds maximum length of 128 characters' });
|
||||
}
|
||||
expect(h.handle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replays a cached response and skips the handler', async () => {
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue({ status_code: 201, response_body: '{"id":5}' }) });
|
||||
const res = makeRes();
|
||||
const h = handler('should-not-run');
|
||||
const out = await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
expect(out).toEqual({ id: 5 });
|
||||
expect(h.handle).not.toHaveBeenCalled();
|
||||
expect(db.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining('idempotency_keys'),
|
||||
'k', 1, 'POST', '/api/categories',
|
||||
);
|
||||
});
|
||||
|
||||
it('captures a successful JSON response under the key', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ created: true });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
// Simulate Nest serialising the handler result through the wrapped res.json.
|
||||
res.statusCode = 201;
|
||||
res.json({ created: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.stringContaining('INSERT OR IGNORE INTO idempotency_keys'),
|
||||
'k', 1, 'POST', '/api/categories', 201, '{"created":true}', expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not cache a non-2xx response', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ error: 'bad' });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 400;
|
||||
res.json({ error: 'bad' });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not cache a body that exceeds the 256 KiB cap', async () => {
|
||||
const run = vi.fn();
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const big = { blob: 'x'.repeat(300 * 1024) };
|
||||
const h = handler(big);
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 200;
|
||||
res.json(big);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows a storage failure so the response still succeeds', async () => {
|
||||
const run = vi.fn(() => {
|
||||
throw new Error('db is locked');
|
||||
});
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run });
|
||||
const res = makeRes();
|
||||
const h = handler({ ok: true });
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'POST', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
res.statusCode = 201;
|
||||
const returned = res.json({ ok: true });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(returned).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('treats a PATCH as a mutating method', async () => {
|
||||
const db = makeDb({ get: vi.fn().mockReturnValue(undefined), run: vi.fn() });
|
||||
const res = makeRes();
|
||||
const h = handler('done');
|
||||
await lastValueFrom(
|
||||
new IdempotencyInterceptor(db).intercept(
|
||||
ctx({ method: 'PATCH', headers: { 'x-idempotency-key': 'k' }, path: '/api/categories/1', user: { id: 1 } }, res),
|
||||
h,
|
||||
),
|
||||
);
|
||||
expect(db.get).toHaveBeenCalled();
|
||||
expect(h.handle).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { JourneyController } from '../../../src/nest/journey/journey.controller';
|
||||
import { JourneyPublicController } from '../../../src/nest/journey/journey-public.controller';
|
||||
import { JourneyAddonGuard } from '../../../src/nest/journey/journey-addon.guard';
|
||||
import type { JourneyService } from '../../../src/nest/journey/journey.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 1, username: 'u', role: 'user', email: 'u@example.test' } as User;
|
||||
|
||||
function svc(o: Partial<JourneyService> = {}): JourneyService {
|
||||
return { journeyAddonEnabled: vi.fn().mockReturnValue(true), ...o } as unknown as JourneyService;
|
||||
}
|
||||
|
||||
function thrown(fn: () => unknown): { status: number; body: unknown } {
|
||||
try { fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
async function thrownAsync(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try { await fn(); } catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected throw');
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('JourneyAddonGuard', () => {
|
||||
it('404 when the addon is disabled, passes when enabled', () => {
|
||||
expect(thrown(() => new JourneyAddonGuard(svc({ journeyAddonEnabled: vi.fn().mockReturnValue(false) })).canActivate())).toEqual({ status: 404, body: { error: 'Journey addon is not enabled' } });
|
||||
expect(new JourneyAddonGuard(svc()).canActivate()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyController', () => {
|
||||
it('GET / lists; POST / 400 without title, else creates', () => {
|
||||
expect(new JourneyController(svc({ listJourneys: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).list(user)).toEqual({ journeys: [{ id: 1 }] });
|
||||
expect(thrown(() => new JourneyController(svc()).create(user, { title: ' ' }))).toEqual({ status: 400, body: { error: 'Title is required' } });
|
||||
const createJourney = vi.fn().mockReturnValue({ id: 9 });
|
||||
expect(new JourneyController(svc({ createJourney } as Partial<JourneyService>)).create(user, { title: ' Trip ', trip_ids: [1, '2'] })).toEqual({ id: 9 });
|
||||
expect(createJourney).toHaveBeenCalledWith(1, { title: 'Trip', subtitle: undefined, trip_ids: [1, 2] });
|
||||
});
|
||||
|
||||
it('GET /suggestions + /available-trips', () => {
|
||||
expect(new JourneyController(svc({ getSuggestions: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).suggestions(user)).toEqual({ trips: [{ id: 1 }] });
|
||||
expect(new JourneyController(svc({ listUserTrips: vi.fn().mockReturnValue([{ id: 2 }]) } as Partial<JourneyService>)).availableTrips(user)).toEqual({ trips: [{ id: 2 }] });
|
||||
});
|
||||
|
||||
it('PATCH/DELETE entries map 404', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updateEntry(user, '3', {}))).toEqual({ status: 404, body: { error: 'Entry not found' } });
|
||||
expect(new JourneyController(svc({ updateEntry: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).updateEntry(user, '3', { title: 'x' })).toEqual({ id: 3 });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteEntry(user, '3'))).toEqual({ status: 404, body: { error: 'Entry not found' } });
|
||||
expect(new JourneyController(svc({ deleteEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteEntry(user, '3')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('provider-photos: batch, single 400/403, success', () => {
|
||||
const batch = svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>);
|
||||
expect(new JourneyController(batch).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a', 'b'] })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
|
||||
expect(thrown(() => new JourneyController(svc()).providerPhotos(user, '3', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
|
||||
});
|
||||
|
||||
it('link-photo: 400 without id (accepts legacy photo_id), 403, success', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).linkPhoto(user, '3', {}))).toEqual({ status: 400, body: { error: 'journey_photo_id required' } });
|
||||
const linkPhotoToEntry = vi.fn().mockReturnValue({ id: 5 });
|
||||
const c = new JourneyController(svc({ linkPhotoToEntry } as Partial<JourneyService>));
|
||||
expect(c.linkPhoto(user, '3', { photo_id: 5 })).toEqual({ id: 5 });
|
||||
expect(linkPhotoToEntry).toHaveBeenCalledWith(3, 5, 1);
|
||||
// accepts the canonical journey_photo_id, 403 when the service refuses
|
||||
expect(thrown(() => new JourneyController(svc({ linkPhotoToEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).linkPhoto(user, '3', { journey_photo_id: 9 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('unlink photo (204) maps 404; delete photo 404 then unlinks file', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7'))).toEqual({ status: 404, body: { error: 'Not found or not allowed' } });
|
||||
expect(new JourneyController(svc({ unlinkPhotoFromEntry: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).unlinkPhoto(user, '3', '7')).toBeUndefined();
|
||||
expect(thrown(() => new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deletePhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found' } });
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('gallery upload 400 no files / 403 not allowed, else returns photos', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).uploadGalleryPhotos(user, '3', undefined))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
|
||||
expect(thrown(() => new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File]))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ uploadGalleryPhotos: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).uploadGalleryPhotos(user, '3', [{ filename: 'a.jpg' } as Express.Multer.File])).toEqual({ photos: [{ id: 1 }] });
|
||||
});
|
||||
|
||||
it('GET/PATCH/DELETE /:id map 404', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(new JourneyController(svc({ getJourneyFull: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).get(user, '9')).toEqual({ id: 9 });
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).update(user, '9', {}))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).remove(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
});
|
||||
|
||||
it('trips: POST 400 without trip_id / 403, DELETE 403', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).addTrip(user, '9', {}))).toEqual({ status: 400, body: { error: 'trip_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ addTripToJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addTrip(user, '9', { trip_id: 2 })).toEqual({ success: true });
|
||||
expect(thrown(() => new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeTrip(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('entries under journey: list 404, create 400/404, reorder 400/403', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ listEntries: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).listEntries(user, '9'))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(new JourneyController(svc({ listEntries: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<JourneyService>)).listEntries(user, '9')).toEqual({ entries: [{ id: 1 }] });
|
||||
expect(thrown(() => new JourneyController(svc()).createEntry(user, '9', {}))).toEqual({ status: 400, body: { error: 'entry_date is required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ createEntry: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' }))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
expect(thrown(() => new JourneyController(svc()).reorderEntries(user, '9', { orderedIds: 'no' }))).toEqual({ status: 400, body: { error: 'orderedIds must be an array of numbers' } });
|
||||
expect(thrown(() => new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [1, 2] }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('contributors: add 400/403, update 403, remove 403', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).addContributor(user, '9', {}))).toEqual({ status: 400, body: { error: 'user_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ addContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 })).toEqual({ success: true });
|
||||
expect(thrown(() => new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' }))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(thrown(() => new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).removeContributor(user, '9', '2'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('preferences 403, share-link get/set/delete', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).preferences(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ getJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).getShareLink(user, '9')).toEqual({ link: { token: 'abc' } });
|
||||
expect(thrown(() => new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).setShareLink(user, '9', {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
expect(new JourneyController(svc({ createOrUpdateJourneyShareLink: vi.fn().mockReturnValue({ token: 'abc' }) } as Partial<JourneyService>)).setShareLink(user, '9', { share_timeline: true })).toEqual({ token: 'abc' });
|
||||
expect(thrown(() => new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(false) } as Partial<JourneyService>)).deleteShareLink(user, '9'))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
});
|
||||
|
||||
it('entry photo upload mirrors to Immich only when opted in', async () => {
|
||||
const addPhoto = vi.fn().mockReturnValue({ id: 5 });
|
||||
const uploadToImmich = vi.fn().mockResolvedValue('immich-1');
|
||||
const setPhotoProvider = vi.fn();
|
||||
const s = svc({ addPhoto, immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich, setPhotoProvider } as Partial<JourneyService>);
|
||||
const res = await new JourneyController(s).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {});
|
||||
expect(setPhotoProvider).toHaveBeenCalledWith(5, 'immich', 'immich-1', 1);
|
||||
expect(res).toEqual({ photos: [{ id: 5, provider: 'immich', asset_id: 'immich-1', owner_id: 1 }] });
|
||||
|
||||
const noOptIn = svc({ addPhoto: vi.fn().mockReturnValue({ id: 6 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(false), uploadToImmich } as Partial<JourneyService>);
|
||||
await new JourneyController(noOptIn).uploadEntryPhotos(user, '3', [{ filename: 'b.jpg', originalname: 'b.jpg' } as Express.Multer.File], {});
|
||||
expect(uploadToImmich).toHaveBeenCalledTimes(1); // only the opted-in upload above
|
||||
});
|
||||
|
||||
it('entry photo upload: 400 no files, 403 when nothing added, swallows immich errors and empty ids', async () => {
|
||||
expect(await thrownAsync(() => new JourneyController(svc()).uploadEntryPhotos(user, '3', undefined, {}))).toEqual({ status: 400, body: { error: 'No files uploaded' } });
|
||||
expect(await thrownAsync(() => new JourneyController(svc({ addPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {}))).toEqual({ status: 403, body: { error: 'Not allowed' } });
|
||||
|
||||
// opted in but the immich upload throws → best-effort, the local photo still wins
|
||||
const setPhotoProvider = vi.fn();
|
||||
const blowsUp = svc({ addPhoto: vi.fn().mockReturnValue({ id: 8 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockRejectedValue(new Error('immich down')), setPhotoProvider } as Partial<JourneyService>);
|
||||
expect(await new JourneyController(blowsUp).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], { caption: 'c' })).toEqual({ photos: [{ id: 8 }] });
|
||||
expect(setPhotoProvider).not.toHaveBeenCalled();
|
||||
|
||||
// opted in but immich returns a falsy id → no provider stamping
|
||||
const noId = svc({ addPhoto: vi.fn().mockReturnValue({ id: 9 }), immichAutoUploadEnabled: vi.fn().mockReturnValue(true), uploadToImmich: vi.fn().mockResolvedValue(''), setPhotoProvider } as Partial<JourneyService>);
|
||||
expect(await new JourneyController(noId).uploadEntryPhotos(user, '3', [{ filename: 'a.jpg', originalname: 'a.jpg' } as Express.Multer.File], {})).toEqual({ photos: [{ id: 9 }] });
|
||||
});
|
||||
|
||||
it('provider-photos batch passes the passphrase through when present', () => {
|
||||
const addProviderPhoto = vi.fn().mockReturnValue({ id: 1 });
|
||||
new JourneyController(svc({ addProviderPhoto } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_ids: ['a'], caption: 'cap', passphrase: 'secret' });
|
||||
expect(addProviderPhoto).toHaveBeenCalledWith(3, 1, 'immich', 'a', 'cap', 'secret');
|
||||
// single-photo success path
|
||||
expect(new JourneyController(svc({ addProviderPhoto: vi.fn().mockReturnValue({ id: 2 }) } as Partial<JourneyService>)).providerPhotos(user, '3', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 2 });
|
||||
});
|
||||
|
||||
it('PATCH photos: 404 then returns the updated photo', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' }))).toEqual({ status: 404, body: { error: 'Photo not found' } });
|
||||
expect(new JourneyController(svc({ updatePhoto: vi.fn().mockReturnValue({ id: 7 }) } as Partial<JourneyService>)).updatePhoto(user, '7', { caption: 'x' })).toEqual({ id: 7 });
|
||||
});
|
||||
|
||||
it('DELETE photo unlinks the file when a path exists', () => {
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
|
||||
try {
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 7, file_path: 'journey/a.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '7')).toEqual({ success: true });
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
// a vanished file is swallowed
|
||||
unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
||||
expect(new JourneyController(svc({ deletePhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/b.jpg' }) } as Partial<JourneyService>)).deletePhoto(user, '8')).toEqual({ success: true });
|
||||
} finally {
|
||||
unlinkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('gallery provider-photos: batch (with passphrase), single 400/403, success', () => {
|
||||
const addProviderPhotoToGallery = vi.fn().mockReturnValue({ id: 1 });
|
||||
const batch = new JourneyController(svc({ addProviderPhotoToGallery } as Partial<JourneyService>));
|
||||
expect(batch.galleryProviderPhotos(user, '9', { provider: 'immich', asset_ids: ['a', 'b'], passphrase: 'pw' })).toEqual({ photos: [{ id: 1 }, { id: 1 }], added: 2 });
|
||||
expect(addProviderPhotoToGallery).toHaveBeenCalledWith(9, 1, 'immich', 'a', undefined, 'pw');
|
||||
expect(thrown(() => new JourneyController(svc()).galleryProviderPhotos(user, '9', { provider: 'immich' }))).toEqual({ status: 400, body: { error: 'provider and asset_id required' } });
|
||||
expect(thrown(() => new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' }))).toEqual({ status: 403, body: { error: 'Not allowed or duplicate' } });
|
||||
expect(new JourneyController(svc({ addProviderPhotoToGallery: vi.fn().mockReturnValue({ id: 3 }) } as Partial<JourneyService>)).galleryProviderPhotos(user, '9', { provider: 'immich', asset_id: 'a' })).toEqual({ id: 3 });
|
||||
});
|
||||
|
||||
it('DELETE gallery photo: 404, then unlinks the file when present', () => {
|
||||
expect(thrown(() => new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7'))).toEqual({ status: 404, body: { error: 'Photo not found or not allowed' } });
|
||||
// no file_path → nothing to unlink, returns void
|
||||
expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 7, file_path: null }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '7')).toBeUndefined();
|
||||
const unlinkSpy = vi.spyOn(fs, 'unlinkSync').mockImplementation(() => undefined);
|
||||
try {
|
||||
new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 8, file_path: 'journey/g.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '8');
|
||||
expect(unlinkSpy).toHaveBeenCalledTimes(1);
|
||||
unlinkSpy.mockImplementationOnce(() => { throw new Error('ENOENT'); });
|
||||
expect(new JourneyController(svc({ deleteGalleryPhoto: vi.fn().mockReturnValue({ id: 9, file_path: 'journey/h.jpg' }) } as Partial<JourneyService>)).deleteGalleryPhoto(user, '9')).toBeUndefined();
|
||||
} finally {
|
||||
unlinkSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('PATCH /:id returns the updated journey on success', () => {
|
||||
expect(new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue({ id: 9 }) } as Partial<JourneyService>)).update(user, '9', { title: 'x' })).toEqual({ id: 9 });
|
||||
});
|
||||
|
||||
it('cover upload: 400 without file, 404 when the journey is gone, else returns the journey', () => {
|
||||
expect(thrown(() => new JourneyController(svc()).cover(user, '9', undefined))).toEqual({ status: 400, body: { error: 'No file uploaded' } });
|
||||
expect(thrown(() => new JourneyController(svc({ updateJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File))).toEqual({ status: 404, body: { error: 'Journey not found' } });
|
||||
const updateJourney = vi.fn().mockReturnValue({ id: 9, cover_image: 'journey/c.jpg' });
|
||||
expect(new JourneyController(svc({ updateJourney } as Partial<JourneyService>)).cover(user, '9', { filename: 'c.jpg' } as Express.Multer.File)).toEqual({ id: 9, cover_image: 'journey/c.jpg' });
|
||||
expect(updateJourney).toHaveBeenCalledWith(9, 1, { cover_image: 'journey/c.jpg' });
|
||||
});
|
||||
|
||||
it('DELETE /:id and trips/contributors success paths', () => {
|
||||
expect(new JourneyController(svc({ deleteJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).remove(user, '9')).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ removeTripFromJourney: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeTrip(user, '9', '2')).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ updateContributorRole: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).updateContributor(user, '9', '2', { role: 'editor' })).toEqual({ success: true });
|
||||
expect(new JourneyController(svc({ removeContributor: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).removeContributor(user, '9', '2')).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('addContributor defaults the role to viewer when omitted', () => {
|
||||
const addContributor = vi.fn().mockReturnValue(true);
|
||||
new JourneyController(svc({ addContributor } as Partial<JourneyService>)).addContributor(user, '9', { user_id: 2 });
|
||||
expect(addContributor).toHaveBeenCalledWith(9, 1, 2, 'viewer');
|
||||
});
|
||||
|
||||
it('createEntry returns the entry when the journey exists', () => {
|
||||
expect(new JourneyController(svc({ createEntry: vi.fn().mockReturnValue({ id: 4 }) } as Partial<JourneyService>)).createEntry(user, '9', { entry_date: '2026-01-01' })).toEqual({ id: 4 });
|
||||
});
|
||||
|
||||
it('reorderEntries succeeds for a numeric array', () => {
|
||||
expect(new JourneyController(svc({ reorderEntries: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).reorderEntries(user, '9', { orderedIds: [3, 1, 2] })).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it('preferences returns the result on success', () => {
|
||||
expect(new JourneyController(svc({ updateJourneyPreferences: vi.fn().mockReturnValue({ ok: true }) } as Partial<JourneyService>)).preferences(user, '9', { theme: 'dark' })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('deleteShareLink returns success when removed', () => {
|
||||
expect(new JourneyController(svc({ deleteJourneyShareLink: vi.fn().mockReturnValue(true) } as Partial<JourneyService>)).deleteShareLink(user, '9')).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyPublicController', () => {
|
||||
it('GET /:token 404 / json', () => {
|
||||
expect(thrown(() => new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).get('tok'))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
expect(new JourneyPublicController(svc({ getPublicJourney: vi.fn().mockReturnValue({ id: 1 }) } as Partial<JourneyService>)).get('tok')).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it('photo proxy 404 on invalid token, else streams', async () => {
|
||||
expect(await thrownAsync(() => new JourneyPublicController(svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).photo('tok', '7', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
const streamPhoto = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 2 }), streamPhoto } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).photo('tok', '7', 'original', {} as Response);
|
||||
expect(streamPhoto).toHaveBeenCalledWith({}, 2, 7, 'original');
|
||||
});
|
||||
|
||||
it('legacy photo proxy: 404 invalid token, immich path streams', async () => {
|
||||
expect(await thrownAsync(() => new JourneyPublicController(svc({ validateShareTokenForAsset: vi.fn().mockReturnValue(null) } as Partial<JourneyService>)).legacyPhoto('tok', 'immich', 'a1', '2', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
const streamImmichAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamImmichAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '2', 'original', {} as Response);
|
||||
expect(streamImmichAsset).toHaveBeenCalledWith({}, 5, 'a1', 'original', 5);
|
||||
});
|
||||
|
||||
it('photo proxy streams thumbnails too', async () => {
|
||||
const streamPhoto = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForPhoto: vi.fn().mockReturnValue({ ownerId: 3 }), streamPhoto } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).photo('tok', '7', 'thumbnail', {} as Response);
|
||||
expect(streamPhoto).toHaveBeenCalledWith({}, 3, 7, 'thumbnail');
|
||||
});
|
||||
|
||||
it('legacy photo proxy: synology streams, and a failure becomes a 404 json', async () => {
|
||||
const streamSynologyAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }), streamSynologyAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'synology', 'a1', '2', 'thumbnail', {} as Response);
|
||||
expect(streamSynologyAsset).toHaveBeenCalledWith({}, 5, 5, 'a1', 'thumbnail');
|
||||
|
||||
const status = vi.fn().mockReturnThis();
|
||||
const json = vi.fn();
|
||||
const res = { status, json } as unknown as Response;
|
||||
const failing = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamSynologyAsset: vi.fn().mockRejectedValue(new Error('no synology')) } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(failing).legacyPhoto('tok', 'synology', 'a1', '6', 'original', res);
|
||||
expect(status).toHaveBeenCalledWith(404);
|
||||
expect(json).toHaveBeenCalledWith({ error: 'Provider not supported' });
|
||||
});
|
||||
|
||||
it('legacy photo proxy: falls back to the path ownerId when the token has none', async () => {
|
||||
const streamImmichAsset = vi.fn().mockResolvedValue(undefined);
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 0 }), streamImmichAsset } as Partial<JourneyService>);
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'immich', 'a1', '8', 'original', {} as Response);
|
||||
expect(streamImmichAsset).toHaveBeenCalledWith({}, 8, 'a1', 'original', 8);
|
||||
});
|
||||
|
||||
it('legacy photo proxy: local provider 404s when the resolved file does not exist', async () => {
|
||||
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false);
|
||||
try {
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial<JourneyService>);
|
||||
expect(await thrownAsync(() => new JourneyPublicController(s).legacyPhoto('tok', 'local', 'gone.jpg', '2', 'thumbnail', {} as Response))).toEqual({ status: 404, body: { error: 'Not found' } });
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('legacy photo proxy: local provider cannot escape uploads/journey via a traversal asset id', async () => {
|
||||
// Pretend any path exists so we can inspect exactly what would be served.
|
||||
const existsSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(true);
|
||||
try {
|
||||
const sendFile = vi.fn();
|
||||
const res = { set: vi.fn(), sendFile } as unknown as Response;
|
||||
const s = svc({ validateShareTokenForAsset: vi.fn().mockReturnValue({ ownerId: 5 }) } as Partial<JourneyService>);
|
||||
|
||||
// Express decodes %2F in a single path param to '/', so the handler sees this.
|
||||
await new JourneyPublicController(s).legacyPhoto('tok', 'local', '../../files/secret.pdf', '2', 'original', res);
|
||||
|
||||
expect(sendFile).toHaveBeenCalledTimes(1);
|
||||
const served = sendFile.mock.calls[0][0] as string;
|
||||
// basename() collapses the traversal: the served file stays inside
|
||||
// uploads/journey and never reaches the sibling /uploads/files dir.
|
||||
expect(path.basename(served)).toBe('secret.pdf');
|
||||
expect(served).toMatch(/[\\/]journey[\\/]secret\.pdf$/);
|
||||
expect(served).not.toMatch(/[\\/]files[\\/]/);
|
||||
} finally {
|
||||
existsSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
|
||||
const { createReadStream } = vi.hoisted(() => ({ createReadStream: vi.fn() }));
|
||||
vi.mock('node:fs', () => ({ createReadStream }));
|
||||
|
||||
import { MapsController } from '../../../src/nest/maps/maps.controller';
|
||||
import type { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { User } from '../../../src/types';
|
||||
|
||||
const user = { id: 3 } as User;
|
||||
|
||||
function makeController(svc: Partial<MapsService>) {
|
||||
return new MapsController(svc as MapsService);
|
||||
}
|
||||
|
||||
/** Run an async handler, expecting an HttpException; return its { status, body }. */
|
||||
async function thrown(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected the handler to throw');
|
||||
}
|
||||
|
||||
function withError(status: number, message: string): Error {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe('MapsController (parity with the legacy /api/maps route)', () => {
|
||||
describe('POST /search', () => {
|
||||
it('400 when query is missing', async () => {
|
||||
expect(await thrown(() => makeController({}).search(user, undefined))).toEqual({
|
||||
status: 400, body: { error: 'Search query is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the service result', async () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const res = await makeController({ search }).search(user, 'berlin', 'de');
|
||||
expect(res).toEqual({ places: [], source: 'osm' });
|
||||
expect(search).toHaveBeenCalledWith(3, 'berlin', 'de', undefined);
|
||||
});
|
||||
|
||||
it('400 on a malformed locationBias (non-finite lat/lng)', async () => {
|
||||
const search = vi.fn();
|
||||
const bad = { lat: NaN, lng: 2 };
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x', 'de', bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: lat and lng must be finite numbers' },
|
||||
});
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards a valid locationBias to the service', async () => {
|
||||
const search = vi.fn().mockResolvedValue({ places: [], source: 'osm' });
|
||||
const bias = { lat: 1, lng: 2, radius: 5000 };
|
||||
await makeController({ search }).search(user, 'x', 'de', bias);
|
||||
expect(search).toHaveBeenCalledWith(3, 'x', 'de', bias);
|
||||
});
|
||||
|
||||
it('maps a service error to its status + message', async () => {
|
||||
const search = vi.fn().mockRejectedValue(withError(429, 'Rate limited'));
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({
|
||||
status: 429, body: { error: 'Rate limited' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults a non-Error rejection to 500 + the fallback message', async () => {
|
||||
const search = vi.fn().mockRejectedValue('boom');
|
||||
expect(await thrown(() => makeController({ search }).search(user, 'x'))).toEqual({
|
||||
status: 500, body: { error: 'Search error' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /pois', () => {
|
||||
it('400 when category is missing', async () => {
|
||||
const pois = vi.fn();
|
||||
expect(await thrown(() => makeController({ pois }).pois(undefined, '1', '2', '3', '4'))).toEqual({
|
||||
status: 400, body: { error: 'A category is required' },
|
||||
});
|
||||
expect(pois).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when the bbox has a non-finite value', async () => {
|
||||
const pois = vi.fn();
|
||||
expect(await thrown(() => makeController({ pois }).pois('cafe', 'x', '2', '3', '4'))).toEqual({
|
||||
status: 400, body: { error: 'A valid bbox (south, west, north, east) is required' },
|
||||
});
|
||||
expect(pois).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates a valid request with a parsed numeric bbox', async () => {
|
||||
const pois = vi.fn().mockResolvedValue({ places: [] });
|
||||
const res = await makeController({ pois }).pois('cafe', '1', '2', '3', '4');
|
||||
expect(res).toEqual({ places: [] });
|
||||
expect(pois).toHaveBeenCalledWith('cafe', { south: 1, west: 2, north: 3, east: 4 });
|
||||
});
|
||||
|
||||
it('maps a service error, defaulting to 500', async () => {
|
||||
const pois = vi.fn().mockRejectedValue(new Error('Overpass down'));
|
||||
expect(await thrown(() => makeController({ pois }).pois('cafe', '1', '2', '3', '4'))).toEqual({
|
||||
status: 500, body: { error: 'Overpass down' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /autocomplete', () => {
|
||||
it('returns the disabled envelope when the kill-switch is off', async () => {
|
||||
const autocomplete = vi.fn();
|
||||
const res = await makeController({ autocompleteDisabled: () => true, autocomplete }).autocomplete(user, 'be');
|
||||
expect(res).toEqual({ suggestions: [], source: 'disabled' });
|
||||
expect(autocomplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 when input is missing or not a string', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
expect(await thrown(() => c.autocomplete(user, undefined))).toEqual({ status: 400, body: { error: 'Input is required' } });
|
||||
expect(await thrown(() => c.autocomplete(user, 123 as unknown as string))).toEqual({ status: 400, body: { error: 'Input is required' } });
|
||||
});
|
||||
|
||||
it('400 when input is too long', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
expect(await thrown(() => c.autocomplete(user, 'x'.repeat(201)))).toEqual({
|
||||
status: 400, body: { error: 'Input too long (max 200 chars)' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 on a malformed locationBias', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
const bad = { low: { lat: 1, lng: NaN }, high: { lat: 2, lng: 3 } };
|
||||
expect(await thrown(() => c.autocomplete(user, 'be', undefined, bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: low and high must have finite lat and lng' },
|
||||
});
|
||||
});
|
||||
|
||||
it('400 when locationBias is missing the high corner', async () => {
|
||||
const c = makeController({ autocompleteDisabled: () => false });
|
||||
const bad = { low: { lat: 1, lng: 2 } } as never;
|
||||
expect(await thrown(() => c.autocomplete(user, 'be', undefined, bad))).toEqual({
|
||||
status: 400, body: { error: 'Invalid locationBias: low and high must have finite lat and lng' },
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates a valid request', async () => {
|
||||
const autocomplete = vi.fn().mockResolvedValue({ suggestions: [], source: 'osm' });
|
||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||
await makeController({ autocompleteDisabled: () => false, autocomplete }).autocomplete(user, 'be', 'en', bias);
|
||||
expect(autocomplete).toHaveBeenCalledWith(3, 'be', 'en', bias);
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
const autocomplete = vi.fn().mockRejectedValue(withError(503, 'Upstream down'));
|
||||
const c = makeController({ autocompleteDisabled: () => false, autocomplete });
|
||||
expect(await thrown(() => c.autocomplete(user, 'be'))).toEqual({
|
||||
status: 503, body: { error: 'Upstream down' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /details/:placeId', () => {
|
||||
it('returns the disabled envelope when off', async () => {
|
||||
const res = await makeController({ detailsDisabled: () => true }).details(user, 'p1');
|
||||
expect(res).toEqual({ place: null, disabled: true });
|
||||
});
|
||||
|
||||
it('uses the expanded lookup when expand is set', async () => {
|
||||
const detailsExpanded = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
||||
const details = vi.fn();
|
||||
await makeController({ detailsDisabled: () => false, detailsExpanded, details })
|
||||
.details(user, 'p1', 'full', 'de', '1');
|
||||
expect(detailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
||||
expect(details).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the plain lookup without expand', async () => {
|
||||
const details = vi.fn().mockResolvedValue({ place: { id: 'p1' } });
|
||||
await makeController({ detailsDisabled: () => false, details }).details(user, 'p1', undefined, 'de');
|
||||
expect(details).toHaveBeenCalledWith(3, 'p1', 'de');
|
||||
});
|
||||
|
||||
it('maps a service error', async () => {
|
||||
const details = vi.fn().mockRejectedValue(withError(404, 'Not found'));
|
||||
expect(await thrown(() => makeController({ detailsDisabled: () => false, details }).details(user, 'p1'))).toEqual({
|
||||
status: 404, body: { error: 'Not found' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /place-photo/:placeId', () => {
|
||||
it('returns { photoUrl: null } when photos are disabled (non-coords)', async () => {
|
||||
const photo = vi.fn();
|
||||
const res = await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'p1', '1', '2');
|
||||
expect(res).toEqual({ photoUrl: null });
|
||||
expect(photo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('bypasses the kill-switch for coords: ids', async () => {
|
||||
const photo = vi.fn().mockResolvedValue({ photoUrl: 'u', attribution: null });
|
||||
await makeController({ photosDisabled: () => true, photo }).placePhoto(user, 'coords:1,2', '1', '2', 'Spot');
|
||||
expect(photo).toHaveBeenCalledWith(3, 'coords:1,2', 1, 2, 'Spot');
|
||||
});
|
||||
|
||||
it('maps a 4xx service error', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(withError(404, 'No photo available'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({
|
||||
status: 404, body: { error: 'No photo available' },
|
||||
});
|
||||
});
|
||||
|
||||
it('logs and maps a 5xx service error', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(withError(502, 'Upstream failed'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1', '1', '2'))).toEqual({
|
||||
status: 502, body: { error: 'Upstream failed' },
|
||||
});
|
||||
expect(console.error).toHaveBeenCalledWith('Place photo error:', expect.any(Error));
|
||||
});
|
||||
|
||||
it('defaults a status-less error to 500 and parses NaN coords', async () => {
|
||||
const photo = vi.fn().mockRejectedValue(new Error('Error fetching photo'));
|
||||
expect(await thrown(() => makeController({ photosDisabled: () => false, photo }).placePhoto(user, 'p1'))).toEqual({
|
||||
status: 500, body: { error: 'Error fetching photo' },
|
||||
});
|
||||
const [, , lat, lng] = photo.mock.calls[0];
|
||||
expect(Number.isNaN(lat)).toBe(true);
|
||||
expect(Number.isNaN(lng)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /place-photo/:placeId/bytes', () => {
|
||||
function makeRes() {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
status: vi.fn(function (this: unknown, c: number) { (res as { statusCode: number }).statusCode = c; return res; }),
|
||||
json: vi.fn(),
|
||||
set: vi.fn(),
|
||||
type: vi.fn(),
|
||||
};
|
||||
return res as unknown as Response & { status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn>; set: ReturnType<typeof vi.fn>; type: ReturnType<typeof vi.fn> };
|
||||
}
|
||||
|
||||
beforeEach(() => createReadStream.mockReset());
|
||||
|
||||
it('404 when the photo is not cached', () => {
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => null }).placePhotoBytes('p1', res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
expect(createReadStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('streams the cached file with image/jpeg + an immutable cache header on a hit', () => {
|
||||
const stream = { on: vi.fn().mockReturnThis(), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
expect(res.set).toHaveBeenCalledWith('Cache-Control', 'public, max-age=2592000, immutable');
|
||||
expect(res.type).toHaveBeenCalledWith('image/jpeg');
|
||||
expect(createReadStream).toHaveBeenCalledWith('/cache/p1.jpg');
|
||||
expect(stream.pipe).toHaveBeenCalledWith(res);
|
||||
});
|
||||
|
||||
it('falls back to 404 when the read stream errors', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
onError();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Photo not cached' });
|
||||
});
|
||||
|
||||
it('does not re-send a 404 when the stream errors after headers were flushed', () => {
|
||||
let onError: () => void = () => {};
|
||||
const stream = { on: vi.fn((ev: string, cb: () => void) => { if (ev === 'error') onError = cb; return stream; }), pipe: vi.fn() };
|
||||
createReadStream.mockReturnValue(stream);
|
||||
const res = makeRes();
|
||||
(res as { headersSent: boolean }).headersSent = true;
|
||||
makeController({ photoBytesPath: () => '/cache/p1.jpg' }).placePhotoBytes('p1', res);
|
||||
onError();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reverse', () => {
|
||||
it('400 when lat/lng missing', async () => {
|
||||
expect(await thrown(() => makeController({}).reverse(undefined, '2'))).toEqual({
|
||||
status: 400, body: { error: 'lat and lng required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the reverse result', async () => {
|
||||
const reverse = vi.fn().mockResolvedValue({ name: 'Spot', address: 'Street 1' });
|
||||
expect(await makeController({ reverse }).reverse('1', '2', 'de')).toEqual({ name: 'Spot', address: 'Street 1' });
|
||||
});
|
||||
|
||||
it('swallows a failure into an empty result (no error)', async () => {
|
||||
const reverse = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
expect(await makeController({ reverse }).reverse('1', '2')).toEqual({ name: null, address: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /resolve-url', () => {
|
||||
it('400 when url missing or not a string', async () => {
|
||||
expect(await thrown(() => makeController({}).resolveUrl(undefined))).toEqual({ status: 400, body: { error: 'URL is required' } });
|
||||
});
|
||||
|
||||
it('returns the resolved coordinates', async () => {
|
||||
const resolveUrl = vi.fn().mockResolvedValue({ lat: 1, lng: 2, name: null, address: null });
|
||||
expect(await makeController({ resolveUrl }).resolveUrl('https://maps.app.goo.gl/x')).toEqual({ lat: 1, lng: 2, name: null, address: null });
|
||||
});
|
||||
|
||||
it('400 when url is not a string', async () => {
|
||||
expect(await thrown(() => makeController({}).resolveUrl(42 as unknown as string))).toEqual({
|
||||
status: 400, body: { error: 'URL is required' },
|
||||
});
|
||||
});
|
||||
|
||||
it('maps a service error, defaulting to 400', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue(new Error('Failed to resolve URL'));
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to resolve URL' },
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an explicit status on the thrown error', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue(withError(422, 'Unsupported link'));
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 422, body: { error: 'Unsupported link' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the default message when a non-Error is thrown', async () => {
|
||||
const resolveUrl = vi.fn().mockRejectedValue('nope');
|
||||
expect(await thrown(() => makeController({ resolveUrl }).resolveUrl('bad'))).toEqual({
|
||||
status: 400, body: { error: 'Failed to resolve URL' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /reverse', () => {
|
||||
it('forwards lang through to the service', async () => {
|
||||
const reverse = vi.fn().mockResolvedValue({ name: null, address: null });
|
||||
await makeController({ reverse }).reverse('1', '2', 'fr');
|
||||
expect(reverse).toHaveBeenCalledWith('1', '2', 'fr');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user