mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6af1d67a2 | |||
| ad893eb1cc | |||
| b25eb18ea4 | |||
| 8410d7c4a5 |
@@ -34,4 +34,5 @@ jobs:
|
||||
command: cves
|
||||
image: trek:scan
|
||||
only-severities: critical,high
|
||||
only-fixed: true
|
||||
exit-code: true
|
||||
|
||||
+15
-2
@@ -1,3 +1,10 @@
|
||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
||||
# The binary and its runtime behaviour are identical to the apt package.
|
||||
FROM golang:1.25-alpine AS gosu-build
|
||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
||||
|
||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||
FROM node:24-alpine AS shared-builder
|
||||
WORKDIR /app
|
||||
@@ -44,7 +51,7 @@ COPY server/package.json ./server/
|
||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
||||
npm ci --workspace=server --omit=dev && \
|
||||
ARCH=$(dpkg --print-architecture) && \
|
||||
if [ "$ARCH" = "amd64" ]; then \
|
||||
@@ -60,6 +67,9 @@ RUN apt-get update && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||
|
||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
||||
|
||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||
# Prevent Qt from probing for a display in headless containers.
|
||||
ENV QT_QPA_PLATFORM=offscreen
|
||||
@@ -95,5 +105,8 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||
|
||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||
@@ -90,7 +91,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧳 Travel management
|
||||
|
||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||
@@ -108,6 +109,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
- **Invite links** — one-time or reusable links with expiry
|
||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **2FA** — TOTP + backup codes
|
||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||
|
||||
</td>
|
||||
@@ -128,13 +130,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### 🧩 Addons (admin-toggleable)
|
||||
|
||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
||||
- **Documents** — file attachments on trips, places, and reservations
|
||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||
|
||||
</td>
|
||||
@@ -156,8 +158,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
#### ⚙️ Admin & customisation
|
||||
|
||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||
|
||||
</td>
|
||||
@@ -191,9 +194,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -202,7 +205,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
||||
|
||||
</div>
|
||||
|
||||
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||
|
||||
<br />
|
||||
|
||||
@@ -263,7 +266,7 @@ Then:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -311,6 +314,9 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
||||
|
||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
||||
|
||||
<h3>Rotating the Encryption Key</h3>
|
||||
|
||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||
@@ -397,12 +403,14 @@ Caddy handles TLS and WebSockets automatically.
|
||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||
| **OIDC / SSO** | | |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.22
|
||||
version: 3.1.0
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.22"
|
||||
appVersion: "3.1.0"
|
||||
|
||||
@@ -28,6 +28,12 @@ data:
|
||||
{{- if .Values.env.COOKIE_SECURE }}
|
||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION }}
|
||||
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
||||
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.env.TRUST_PROXY }}
|
||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -34,6 +34,10 @@ env:
|
||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||
# COOKIE_SECURE: "true"
|
||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||
# SESSION_DURATION: "24h"
|
||||
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
||||
# SESSION_DURATION_REMEMBER: "30d"
|
||||
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
||||
# TRUST_PROXY: "1"
|
||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||
# ALLOW_INTERNAL_NETWORK: "false"
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/client",
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -58,11 +58,12 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
@@ -80,8 +81,8 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0",
|
||||
"vitest": "^3.2.4"
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
||||
* trip's store after the network comes back via the `online` event. */
|
||||
export function getActiveTrips(): string[] {
|
||||
return Array.from(activeTrips)
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import MarkdownToolbar from './MarkdownToolbar';
|
||||
import React from 'react';
|
||||
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
||||
}
|
||||
|
||||
describe('MarkdownToolbar', () => {
|
||||
let onUpdate: ReturnType<typeof vi.fn>;
|
||||
let onUpdate: Mock<(value: string) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
onUpdate = vi.fn();
|
||||
onUpdate = vi.fn<(value: string) => void>();
|
||||
});
|
||||
|
||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import OfflineBanner from './OfflineBanner'
|
||||
|
||||
vi.mock('../../sync/mutationQueue', () => ({
|
||||
mutationQueue: {
|
||||
pendingCount: vi.fn(),
|
||||
failedCount: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
||||
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
||||
})
|
||||
|
||||
describe('OfflineBanner (B3 surface)', () => {
|
||||
it('shows the failed pill when failedCount > 0 while online', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(2)
|
||||
|
||||
render(<OfflineBanner />)
|
||||
|
||||
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stays hidden when online with nothing pending or failed', async () => {
|
||||
pendingCount.mockResolvedValue(0)
|
||||
failedCount.mockResolvedValue(0)
|
||||
|
||||
const { container } = render(<OfflineBanner />)
|
||||
// Give the async poll a tick to resolve.
|
||||
await waitFor(() => expect(failedCount).toHaveBeenCalled())
|
||||
expect(container.querySelector('[role="status"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@
|
||||
* OfflineBanner — connectivity + sync state indicator.
|
||||
*
|
||||
* States:
|
||||
* N failed → red pill "N changes failed to sync" (takes priority)
|
||||
* offline + N queued → amber pill "Offline · N queued"
|
||||
* offline + 0 queued → amber pill "Offline"
|
||||
* online + N pending → blue pill "Syncing N…"
|
||||
@@ -12,7 +13,7 @@
|
||||
* headers. On mobile it hovers just above the bottom tab bar.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { mutationQueue } from '../../sync/mutationQueue'
|
||||
|
||||
const POLL_MS = 3_000
|
||||
@@ -20,6 +21,7 @@ const POLL_MS = 3_000
|
||||
export default function OfflineBanner(): React.ReactElement | null {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => setIsOnline(true)
|
||||
@@ -35,26 +37,36 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function poll() {
|
||||
const n = await mutationQueue.pendingCount()
|
||||
if (!cancelled) setPendingCount(n)
|
||||
const [n, failed] = await Promise.all([
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
])
|
||||
if (!cancelled) {
|
||||
setPendingCount(n)
|
||||
setFailedCount(failed)
|
||||
}
|
||||
}
|
||||
poll()
|
||||
const id = setInterval(poll, POLL_MS)
|
||||
return () => { cancelled = true; clearInterval(id) }
|
||||
}, [])
|
||||
|
||||
const hidden = isOnline && pendingCount === 0
|
||||
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
||||
if (hidden) return null
|
||||
|
||||
const offline = !isOnline
|
||||
const bg = offline ? '#92400e' : '#1e40af'
|
||||
// Failed mutations are the most important signal — they mean data was dropped.
|
||||
const failed = failedCount > 0
|
||||
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
||||
const text = '#fff'
|
||||
|
||||
const label = offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
const label = failed
|
||||
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
||||
: offline
|
||||
? pendingCount > 0
|
||||
? `Offline · ${pendingCount} queued`
|
||||
: 'Offline'
|
||||
: `Syncing ${pendingCount}…`
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,9 +94,11 @@ export default function OfflineBanner(): React.ReactElement | null {
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
{failed
|
||||
? <AlertTriangle size={12} />
|
||||
: offline
|
||||
? <WifiOff size={12} />
|
||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||
}
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,11 @@ import { MapViewGL } from './MapViewGL'
|
||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||
//
|
||||
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
||||
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
|
||||
// vector tiles are cached opportunistically by the Service Worker as you view
|
||||
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MapViewAuto(props: any) {
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
|
||||
@@ -31,21 +31,29 @@ const glMap = vi.hoisted(() => ({
|
||||
vi.mock('mapbox-gl', () => ({
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: vi.fn(() => glMap),
|
||||
Marker: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
})),
|
||||
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
getElement: vi.fn(() => document.createElement('div')),
|
||||
}
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return { extend: vi.fn().mockReturnThis() }
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(() => ({
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
})),
|
||||
Popup: vi.fn(function () {
|
||||
return {
|
||||
setLngLat: vi.fn().mockReturnThis(),
|
||||
setHTML: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn(),
|
||||
}
|
||||
}),
|
||||
},
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
@@ -63,7 +71,9 @@ vi.mock('./locationMarkerMapbox', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('./reservationsMapbox', () => ({
|
||||
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
||||
ReservationMapboxOverlay: vi.fn(function () {
|
||||
return { update: vi.fn() }
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../hooks/useGeolocation', () => ({
|
||||
|
||||
@@ -18,16 +18,16 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors } from '../../utils/dayOrder'
|
||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
|
||||
import {
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||
type MergedItem,
|
||||
} from '../../utils/dayMerge'
|
||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||
@@ -152,6 +152,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [isCalculating, setIsCalculating] = useState(false)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
const legsAbortRef = useRef<AbortController | null>(null)
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [lockedIds, setLockedIds] = useState(new Set())
|
||||
@@ -379,12 +381,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||
useEffect(() => {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
@@ -392,7 +390,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const r = it.data
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
@@ -408,7 +406,32 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
if (runs.length === 0) { setRouteLegs({}); return }
|
||||
|
||||
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
||||
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
||||
// the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
const day = days.find(d => d.id === selectedDayId)
|
||||
const { morning: startHotel, evening: endHotel } =
|
||||
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
|
||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
||||
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
|
||||
// legs connect even when the day starts or ends with a booking rather than a place.
|
||||
const wayPts: { lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
|
||||
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
|
||||
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
|
||||
}
|
||||
}
|
||||
const firstWay = wayPts[0]
|
||||
const lastWay = wayPts[wayPts.length - 1]
|
||||
const wantTop = !!(startHotel && firstWay)
|
||||
const wantBottom = !!(endHotel && lastWay)
|
||||
|
||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
legsAbortRef.current = controller
|
||||
@@ -422,9 +445,27 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (err instanceof Error && err.name === 'AbortError') return
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) setRouteLegs(map)
|
||||
|
||||
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
||||
return r.legs[0]
|
||||
} catch { return undefined }
|
||||
}
|
||||
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
||||
if (wantTop) {
|
||||
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
|
||||
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
||||
}
|
||||
if (wantBottom) {
|
||||
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
||||
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
||||
}
|
||||
|
||||
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
||||
})()
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
@@ -938,6 +979,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1085,6 +1128,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
setRouteInfo,
|
||||
routeLegs,
|
||||
setRouteLegs,
|
||||
hotelLegs,
|
||||
setHotelLegs,
|
||||
legsAbortRef,
|
||||
draggingId,
|
||||
setDraggingId,
|
||||
@@ -1427,6 +1472,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||
}}
|
||||
>
|
||||
{isSelected && hotelLegs.top && (
|
||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
||||
)}
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||
@@ -2057,6 +2105,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})
|
||||
)}
|
||||
{isSelected && hotelLegs.bottom && (
|
||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
||||
)}
|
||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||
<div
|
||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Car, Footprints } from 'lucide-react'
|
||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||
@@ -19,3 +19,60 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
||||
* accommodation with the drive to/from it. Rendered above the first place (the
|
||||
* morning departure from the hotel) and below the last place (the evening return),
|
||||
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
||||
*/
|
||||
export function HotelRouteConnector({
|
||||
seg,
|
||||
profile,
|
||||
name,
|
||||
placement,
|
||||
}: {
|
||||
seg: RouteSegment
|
||||
profile: 'driving' | 'walking'
|
||||
name: string
|
||||
placement: 'top' | 'bottom'
|
||||
}) {
|
||||
const driving = profile === 'driving'
|
||||
const Icon = driving ? Car : Footprints
|
||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
||||
const hotelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
const travelRow = (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
||||
<div style={line} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
||||
<Icon size={11} strokeWidth={2} />
|
||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span>{seg.distanceText}</span>
|
||||
</div>
|
||||
<div style={line} />
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
||||
{placement === 'top' ? (
|
||||
<>
|
||||
{hotelRow}
|
||||
{travelRow}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{travelRow}
|
||||
{hotelRow}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,6 +253,101 @@ describe('PlaceFormModal', () => {
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
||||
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
||||
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
||||
// it yields no usable place the modal must fall back to the reliable text search
|
||||
// instead of dead-ending on "Place search failed".
|
||||
|
||||
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
||||
await user.type(searchInput, 'Eiffel');
|
||||
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
||||
return screen.findByText('Paris, France');
|
||||
}
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
// Form is populated from the search fallback, and no error toast is shown.
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () =>
|
||||
HttpResponse.json({
|
||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
||||
source: 'openstreetmap',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
||||
const addToast = vi.fn();
|
||||
window.__addToast = addToast;
|
||||
const user = userEvent.setup();
|
||||
server.use(
|
||||
http.post('/api/maps/autocomplete', () =>
|
||||
HttpResponse.json({
|
||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
||||
source: 'nominatim',
|
||||
}),
|
||||
),
|
||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
||||
);
|
||||
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
const suggestion = await openSuggestion(user);
|
||||
await user.click(suggestion);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
||||
});
|
||||
delete window.__addToast;
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||
// hasMapsKey is false by default in beforeEach
|
||||
render(<PlaceFormModal {...defaultProps} />);
|
||||
|
||||
@@ -249,15 +249,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||
setIsSearchingMaps(true)
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place) {
|
||||
handleSelectMapsResult(result.place)
|
||||
// The details lookup is a fragile second hop — it can fail when the
|
||||
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
||||
// or on any upstream error. Treat a missing/coordinate-less place as a
|
||||
// miss and fall back to the reliable text-search path the search button
|
||||
// uses (its results already carry coordinates), so dropdown items stay
|
||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
||||
let place: Record<string, unknown> | null = null
|
||||
try {
|
||||
const result = await mapsApi.details(suggestion.placeId, language)
|
||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
||||
place = result.place
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch place details:', err)
|
||||
console.error('Place suggestion lookup failed:', err)
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
} finally {
|
||||
|
||||
@@ -647,5 +647,43 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.queryByText('Participants')).toBeNull();
|
||||
});
|
||||
|
||||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
||||
expect(scroll.style.overflowY).toBe('auto');
|
||||
expect(scroll.style.minHeight).toBe('0px');
|
||||
// flex must allow the region to shrink/grow within the capped card
|
||||
expect(scroll.style.flex).not.toBe('');
|
||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
||||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
||||
const p = buildPlace({ id: 201, description: longWord } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
||||
expect(descDiv).toBeTruthy();
|
||||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
||||
expect(descDiv.style.wordBreak).toBe('break-word');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
||||
// Both description and notes containers must keep their natural height
|
||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
||||
expect(notes.length).toBe(2);
|
||||
for (const el of notes) {
|
||||
expect(el.style.flexShrink).toBe('0');
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||
|
||||
{/* Content — scrollable */}
|
||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Description / Summary */}
|
||||
{(place.description || googleDetails?.summary) && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
||||
{selectedDayId && (
|
||||
assignmentInDay ? (
|
||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
||||
{/* Avatar with open/closed ring + tag */}
|
||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -21,6 +21,7 @@ interface CachedTripRow {
|
||||
export default function OfflineTab(): React.ReactElement {
|
||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||
const [pendingCount, setPendingCount] = useState(0)
|
||||
const [failedCount, setFailedCount] = useState(0)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -28,11 +29,13 @@ export default function OfflineTab(): React.ReactElement {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [metas, pending] = await Promise.all([
|
||||
const [metas, pending, failed] = await Promise.all([
|
||||
offlineDb.syncMeta.toArray(),
|
||||
mutationQueue.pendingCount(),
|
||||
mutationQueue.failedCount(),
|
||||
])
|
||||
setPendingCount(pending)
|
||||
setFailedCount(failed)
|
||||
|
||||
const result: CachedTripRow[] = []
|
||||
for (const meta of metas) {
|
||||
@@ -85,6 +88,7 @@ export default function OfflineTab(): React.ReactElement {
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Stat label="Cached trips" value={rows.length} />
|
||||
<Stat label="Pending changes" value={pendingCount} />
|
||||
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -165,13 +169,14 @@ export default function OfflineTab(): React.ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
||||
return (
|
||||
<div className="border border-edge bg-surface-secondary" style={{
|
||||
padding: '8px 14px', borderRadius: 8,
|
||||
minWidth: 100,
|
||||
}}>
|
||||
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
||||
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onToggle}
|
||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
||||
style={{
|
||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||
|
||||
@@ -288,4 +288,26 @@ describe('TripFormModal', () => {
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
expect(dayInput).toBeInTheDocument();
|
||||
expect(dayInput.value).toBe('7');
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
expect(dayInput.value).toBe('');
|
||||
});
|
||||
|
||||
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = vi.fn();
|
||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
||||
fireEvent.change(dayInput, { target: { value: '' } });
|
||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
||||
await user.click(submitBtn.closest('button')!);
|
||||
await screen.findByText('Number of days is required');
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
reminder_days: 0 as number,
|
||||
day_count: 7,
|
||||
day_count: 7 as number | '',
|
||||
})
|
||||
const [customReminder, setCustomReminder] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -100,6 +100,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||
setError(t('dashboard.endDateError')); return
|
||||
}
|
||||
if (!formData.start_date && !formData.end_date) {
|
||||
const dc = Number(formData.day_count)
|
||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
||||
setError(t('dashboard.dayCountRequired')); return
|
||||
}
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await onSave({
|
||||
@@ -108,7 +114,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
reminder_days: formData.reminder_days,
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
||||
})
|
||||
const createdTrip = result ? result.trip : undefined
|
||||
// Add selected members for newly created trips
|
||||
@@ -320,7 +326,12 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
{t('dashboard.dayCount')}
|
||||
</label>
|
||||
<input type="number" min={1} max={365} value={formData.day_count}
|
||||
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||
onChange={e => {
|
||||
const raw = e.target.value
|
||||
if (raw === '') { update('day_count', ''); return }
|
||||
const n = Math.floor(Number(raw))
|
||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
||||
}}
|
||||
className={inputCls} />
|
||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||
</div>
|
||||
|
||||
+137
-3
@@ -27,6 +27,12 @@ export interface QueuedMutation {
|
||||
tempId?: number;
|
||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||
entityId?: number;
|
||||
/**
|
||||
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
|
||||
* the temp id of the target. The url carries an `{id}` placeholder that the
|
||||
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
||||
*/
|
||||
tempEntityId?: number;
|
||||
}
|
||||
|
||||
export interface SyncMeta {
|
||||
@@ -41,13 +47,48 @@ export interface SyncMeta {
|
||||
export interface BlobCacheEntry {
|
||||
/** Relative URL, e.g. "/api/files/42/download" */
|
||||
url: string;
|
||||
/**
|
||||
* Trip this blob belongs to, so it is evicted together with the trip in
|
||||
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
||||
*/
|
||||
tripId: number;
|
||||
blob: Blob;
|
||||
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
||||
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
||||
bytes: number;
|
||||
mime: string;
|
||||
cachedAt: number;
|
||||
}
|
||||
|
||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The offline DB is scoped per user so that one account can never read another
|
||||
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
||||
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
||||
*/
|
||||
const ANON_DB_NAME = 'trek-offline';
|
||||
|
||||
function userDbName(userId: number | string): string {
|
||||
return `trek-offline-u${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
||||
* app load (before loadUser resolves) is already the correct per-user one — the
|
||||
* PWA can render cached data offline without leaking across users.
|
||||
*/
|
||||
function initialDbName(): string {
|
||||
try {
|
||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
||||
if (!raw) return ANON_DB_NAME;
|
||||
const id = JSON.parse(raw)?.state?.user?.id;
|
||||
return id != null ? userDbName(id) : ANON_DB_NAME;
|
||||
} catch {
|
||||
return ANON_DB_NAME;
|
||||
}
|
||||
}
|
||||
|
||||
class TrekOfflineDb extends Dexie {
|
||||
trips!: Table<Trip, number>;
|
||||
days!: Table<Day, number>;
|
||||
@@ -65,8 +106,8 @@ class TrekOfflineDb extends Dexie {
|
||||
syncMeta!: Table<SyncMeta, number>;
|
||||
blobCache!: Table<BlobCacheEntry, string>;
|
||||
|
||||
constructor() {
|
||||
super('trek-offline');
|
||||
constructor(name: string = ANON_DB_NAME) {
|
||||
super(name);
|
||||
|
||||
this.version(1).stores({
|
||||
trips: 'id',
|
||||
@@ -88,10 +129,67 @@ class TrekOfflineDb extends Dexie {
|
||||
tags: 'id',
|
||||
categories: 'id',
|
||||
});
|
||||
|
||||
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
||||
// bounded by an LRU budget (see enforceBlobBudget).
|
||||
this.version(3).stores({
|
||||
blobCache: 'url, cachedAt, tripId',
|
||||
}).upgrade(async (tx) => {
|
||||
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
||||
if (row.tripId == null) row.tripId = -1;
|
||||
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const offlineDb = new TrekOfflineDb();
|
||||
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
||||
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
||||
// import it directly, while every access forwards to the current connection.
|
||||
let _db = new TrekOfflineDb(initialDbName());
|
||||
|
||||
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
||||
get(_target, prop) {
|
||||
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
||||
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
||||
},
|
||||
set(_target, prop, value) {
|
||||
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
||||
return true;
|
||||
},
|
||||
}) as TrekOfflineDb;
|
||||
|
||||
async function switchTo(name: string): Promise<void> {
|
||||
if (_db.name === name) {
|
||||
if (!_db.isOpen()) await _db.open();
|
||||
return;
|
||||
}
|
||||
if (_db.isOpen()) _db.close();
|
||||
_db = new TrekOfflineDb(name);
|
||||
await _db.open();
|
||||
}
|
||||
|
||||
/** Point the offline DB at a specific user's scoped database (call on login). */
|
||||
export async function reopenForUser(userId: number | string): Promise<void> {
|
||||
await switchTo(userDbName(userId));
|
||||
}
|
||||
|
||||
/** Point the offline DB at the anonymous database (call on logout). */
|
||||
export async function reopenAnonymous(): Promise<void> {
|
||||
await switchTo(ANON_DB_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the current user's scoped database entirely and return to the anonymous
|
||||
* DB. Used on logout so no trace of the account's data remains on the device.
|
||||
*/
|
||||
export async function deleteCurrentUserDb(): Promise<void> {
|
||||
if (_db.name !== ANON_DB_NAME) {
|
||||
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
||||
}
|
||||
_db = new TrekOfflineDb(ANON_DB_NAME);
|
||||
await _db.open();
|
||||
}
|
||||
|
||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -166,6 +264,40 @@ export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
||||
* documents never starve the map-tile cache (sized at MAX_TILES in
|
||||
* tilePrefetcher.ts) for the origin's storage quota.
|
||||
*/
|
||||
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
||||
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
||||
|
||||
/**
|
||||
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
||||
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
||||
* is a reasonable proxy for access for write-once document blobs.
|
||||
*/
|
||||
export async function enforceBlobBudget(
|
||||
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
||||
maxBytes = BLOB_CACHE_MAX_BYTES,
|
||||
): Promise<void> {
|
||||
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
||||
let count = entries.length;
|
||||
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
||||
if (count <= maxCount && totalBytes <= maxBytes) return;
|
||||
|
||||
const toDelete: string[] = [];
|
||||
for (const e of entries) {
|
||||
if (count <= maxCount && totalBytes <= maxBytes) break;
|
||||
toDelete.push(e.url);
|
||||
totalBytes -= e.bytes ?? 0;
|
||||
count -= 1;
|
||||
}
|
||||
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
@@ -184,6 +316,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
offlineDb.tripMembers,
|
||||
offlineDb.mutationQueue,
|
||||
offlineDb.syncMeta,
|
||||
offlineDb.blobCache,
|
||||
],
|
||||
async () => {
|
||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||
@@ -197,6 +330,7 @@ export async function clearTripData(tripId: number): Promise<void> {
|
||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
||||
},
|
||||
);
|
||||
// Remove the trip row itself outside the transaction since it's a separate table
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
||||
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
|
||||
* for the dashboard widget) for the given base and caches per base in memory +
|
||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||
* currency C converts to base as `amount / rates[C]`.
|
||||
@@ -33,14 +33,19 @@ export function useExchangeRates(base: string) {
|
||||
if (cached) setRates(cached.rates)
|
||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||
let cancelled = false
|
||||
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
||||
.then(r => r.json())
|
||||
.then((d: { rates?: Record<string, number> }) => {
|
||||
if (cancelled || !d?.rates) return
|
||||
const entry = { rates: d.rates, ts: Date.now() }
|
||||
.then((d: Array<{ quote?: string; rate?: number }>) => {
|
||||
if (cancelled || !Array.isArray(d)) return
|
||||
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
||||
const rates: Record<string, number> = { [upper]: 1 }
|
||||
for (const r of d) {
|
||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
||||
}
|
||||
const entry = { rates, ts: Date.now() }
|
||||
mem.set(upper, entry)
|
||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||
setRates(d.rates)
|
||||
setRates(rates)
|
||||
})
|
||||
.catch(() => { /* offline → keep cached/identity */ })
|
||||
return () => { cancelled = true }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
@@ -53,12 +54,6 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
@@ -67,12 +62,15 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
...dayTransports.map(r => {
|
||||
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
||||
return {
|
||||
kind: 'transport' as const,
|
||||
from,
|
||||
to,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
}
|
||||
}),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group located places into driving runs.
|
||||
|
||||
@@ -15,8 +15,11 @@ import '@fontsource/geist-sans/500.css'
|
||||
import '@fontsource/geist-sans/600.css'
|
||||
import './index.css'
|
||||
import { startConnectivityProbe } from './sync/connectivity'
|
||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
||||
|
||||
startConnectivityProbe()
|
||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
||||
requestPersistentStorage()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -20,8 +20,11 @@ beforeEach(() => {
|
||||
} as any);
|
||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||
server.use(
|
||||
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
||||
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
||||
http.get('https://api.frankfurter.dev/v2/rates', () => {
|
||||
return HttpResponse.json([
|
||||
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
|
||||
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
|
||||
]);
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -461,9 +461,15 @@ function CurrencyTool(): React.ReactElement {
|
||||
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
||||
|
||||
const fetchRate = React.useCallback(() => {
|
||||
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setRates(d.rates ?? null))
|
||||
.then((d: Array<{ quote: string; rate: number }>) => {
|
||||
if (!Array.isArray(d)) { setRates(null); return }
|
||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
||||
const map: Record<string, number> = { [from]: 1 }
|
||||
for (const r of d) map[r.quote] = r.rate
|
||||
setRates(map)
|
||||
})
|
||||
.catch(() => setRates(null))
|
||||
}, [from])
|
||||
|
||||
|
||||
@@ -103,6 +103,38 @@ describe('LoginPage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
server.use(
|
||||
http.post('/api/auth/login', async ({ request }) => {
|
||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
||||
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
|
||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
||||
await user.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
||||
it('shows a Register button to switch to registration mode', async () => {
|
||||
// Default appConfig has allow_registration: true, has_users: true
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||
import { useLogin } from './login/useLogin'
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
@@ -9,7 +10,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
const {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
@@ -572,7 +573,16 @@ export default function LoginPage(): React.ReactElement {
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'login' && (
|
||||
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
||||
<span
|
||||
onClick={() => setRememberMe(!rememberMe)}
|
||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
||||
>
|
||||
{t('login.rememberMe')}
|
||||
</span>
|
||||
</div>
|
||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||
|
||||
@@ -636,6 +636,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onImport={() => setShowBookingImport(true)}
|
||||
bookingImportAvailable={bookingImportAvailable}
|
||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
||||
airTrailAvailable={airTrailAvailable}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useLogin() {
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [rememberMe, setRememberMe] = useState<boolean>(false)
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
@@ -242,7 +243,7 @@ export function useLogin() {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
|
||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||
setSavedLoginPassword(password)
|
||||
setPasswordChangeStep(true)
|
||||
@@ -258,7 +259,7 @@ export function useLogin() {
|
||||
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
||||
await register(username, email, password, inviteToken || undefined)
|
||||
} else {
|
||||
const result = await login(email, password)
|
||||
const result = await login(email, password, rememberMe)
|
||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||
setMfaToken(result.mfa_token)
|
||||
setMfaStep(true)
|
||||
@@ -289,7 +290,7 @@ export function useLogin() {
|
||||
return {
|
||||
navigate,
|
||||
mode, setMode,
|
||||
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
||||
isLoading, error, setError, appConfig, inviteToken,
|
||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||
|
||||
@@ -221,11 +221,12 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [isLoading, places])
|
||||
|
||||
// Load trip + files (needed for place inspector file section)
|
||||
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
|
||||
// packing, todo, budget, reservations, files) so offline hydration is uniform
|
||||
// and there's no cross-trip bleed; members/accommodations load alongside.
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||
tripActions.loadFiles(tripId)
|
||||
loadAccommodations()
|
||||
if (!navigator.onLine) {
|
||||
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
||||
@@ -240,13 +241,6 @@ export function useTripPlanner() {
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (tripId) {
|
||||
tripActions.loadReservations(tripId)
|
||||
tripActions.loadBudgetItems?.(tripId)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
useTripWebSocket(tripId)
|
||||
|
||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { accommodationsApi } from '../api/client'
|
||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Accommodation } from '../types'
|
||||
|
||||
export const accommodationRepo = {
|
||||
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const accommodations = await offlineDb.accommodations
|
||||
.where('trip_id').equals(Number(tripId)).toArray()
|
||||
return { accommodations }
|
||||
}
|
||||
const result = await accommodationsApi.list(tripId)
|
||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await accommodationsApi.list(tripId)
|
||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
accommodations: await offlineDb.accommodations
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { budgetApi } from '../api/client'
|
||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { BudgetItem } from '../types'
|
||||
|
||||
export const budgetRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.budgetItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await budgetApi.list(tripId)
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await budgetApi.list(tripId)
|
||||
upsertBudgetItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.budgetItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+14
-10
@@ -1,18 +1,22 @@
|
||||
import { daysApi } from '../api/client'
|
||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Day } from '../types'
|
||||
|
||||
export const dayRepo = {
|
||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.days
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.sortBy('day_number' as keyof Day)
|
||||
return { days: cached as Day[] }
|
||||
}
|
||||
const result = await daysApi.list(tripId)
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await daysApi.list(tripId)
|
||||
upsertDays(result.days)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
days: (await offlineDb.days
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.sortBy('day_number' as keyof Day)) as Day[],
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,18 +1,20 @@
|
||||
import { filesApi } from '../api/client'
|
||||
import { offlineDb, upsertTripFiles } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { TripFile } from '../types'
|
||||
|
||||
export const fileRepo = {
|
||||
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.tripFiles
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { files: cached }
|
||||
}
|
||||
const result = await filesApi.list(tripId)
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await filesApi.list(tripId)
|
||||
upsertTripFiles(result.files)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
files: await offlineDb.tripFiles
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { packingApi } from '../api/client'
|
||||
import { offlineDb, upsertPackingItems } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { PackingItem } from '../types'
|
||||
|
||||
export const packingRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: PackingItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.packingItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await packingApi.list(tripId)
|
||||
upsertPackingItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await packingApi.list(tripId)
|
||||
upsertPackingItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.packingItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ item: PackingItem }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempId = nextTempId()
|
||||
const tempItem: PackingItem = {
|
||||
...(data as Partial<PackingItem>),
|
||||
id: tempId,
|
||||
@@ -51,13 +53,16 @@ export const packingRepo = {
|
||||
const optimistic: PackingItem = { ...(existing ?? {} as PackingItem), ...(data as Partial<PackingItem>), id }
|
||||
await offlineDb.packingItems.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||
body: data,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
return { item: optimistic }
|
||||
}
|
||||
@@ -70,14 +75,16 @@ export const packingRepo = {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.packingItems.delete(id)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/packing/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/packing/{id}` : `/trips/${tripId}/packing/${id}`,
|
||||
body: undefined,
|
||||
resource: 'packingItems',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import { placesApi } from '../api/client'
|
||||
import { offlineDb, upsertPlaces } from '../db/offlineDb'
|
||||
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../sync/mutationQueue'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Place } from '../types'
|
||||
|
||||
export const placeRepo = {
|
||||
async list(tripId: number | string, params?: Record<string, unknown>): Promise<{ places: Place[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.places
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { places: cached }
|
||||
}
|
||||
const result = await placesApi.list(tripId, params)
|
||||
upsertPlaces(result.places)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await placesApi.list(tripId, params)
|
||||
upsertPlaces(result.places)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
places: await offlineDb.places
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async create(tripId: number | string, data: Record<string, unknown> & { name: string }): Promise<{ place: Place }> {
|
||||
if (!navigator.onLine) {
|
||||
const tempId = -(Date.now())
|
||||
const tempId = nextTempId()
|
||||
const tempPlace: Place = {
|
||||
...(data as Partial<Place>),
|
||||
id: tempId,
|
||||
@@ -50,13 +52,16 @@ export const placeRepo = {
|
||||
const optimistic: Place = { ...(existing ?? {} as Place), ...(data as Partial<Place>), id: Number(id) }
|
||||
await offlineDb.places.put(optimistic)
|
||||
const mutId = generateUUID()
|
||||
const isTemp = Number(id) < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'PUT',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: data,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||
})
|
||||
return { place: optimistic }
|
||||
}
|
||||
@@ -69,14 +74,16 @@ export const placeRepo = {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.delete(Number(id))
|
||||
const mutId = generateUUID()
|
||||
const isTemp = Number(id) < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: Number(id),
|
||||
...(isTemp ? { tempEntityId: Number(id) } : {}),
|
||||
})
|
||||
return { success: true }
|
||||
}
|
||||
@@ -90,14 +97,16 @@ export const placeRepo = {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
const mutId = generateUUID()
|
||||
const isTemp = id < 0
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
url: isTemp ? `/trips/${tripId}/places/{id}` : `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
...(isTemp ? { tempEntityId: id } : {}),
|
||||
})
|
||||
}
|
||||
return { deleted: ids, count: ids.length }
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { reservationsApi } from '../api/client'
|
||||
import { offlineDb, upsertReservations } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Reservation } from '../types'
|
||||
|
||||
export const reservationRepo = {
|
||||
async list(tripId: number | string): Promise<{ reservations: Reservation[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.reservations
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { reservations: cached }
|
||||
}
|
||||
const result = await reservationsApi.list(tripId)
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await reservationsApi.list(tripId)
|
||||
upsertReservations(result.reservations)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
reservations: await offlineDb.reservations
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,18 +1,20 @@
|
||||
import { todoApi } from '../api/client'
|
||||
import { offlineDb, upsertTodoItems } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { TodoItem } from '../types'
|
||||
|
||||
export const todoRepo = {
|
||||
async list(tripId: number | string): Promise<{ items: TodoItem[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.todoItems
|
||||
.where('trip_id')
|
||||
.equals(Number(tripId))
|
||||
.toArray()
|
||||
return { items: cached }
|
||||
}
|
||||
const result = await todoApi.list(tripId)
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await todoApi.list(tripId)
|
||||
upsertTodoItems(result.items)
|
||||
return result
|
||||
},
|
||||
async () => ({
|
||||
items: await offlineDb.todoItems
|
||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
+31
-22
@@ -1,33 +1,42 @@
|
||||
import { tripsApi } from '../api/client'
|
||||
import { offlineDb, upsertTrip } from '../db/offlineDb'
|
||||
import { onlineThenCache } from './withOfflineFallback'
|
||||
import type { Trip } from '../types'
|
||||
|
||||
export const tripRepo = {
|
||||
async list(): Promise<{ trips: Trip[]; archivedTrips: Trip[] }> {
|
||||
if (!navigator.onLine) {
|
||||
const all = await offlineDb.trips.toArray()
|
||||
return {
|
||||
trips: all.filter(t => !t.is_archived),
|
||||
archivedTrips: all.filter(t => t.is_archived),
|
||||
}
|
||||
}
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
active.trips.forEach(t => upsertTrip(t))
|
||||
archived.trips.forEach(t => upsertTrip(t))
|
||||
return { trips: active.trips, archivedTrips: archived.trips }
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const [active, archived] = await Promise.all([
|
||||
tripsApi.list(),
|
||||
tripsApi.list({ archived: 1 }),
|
||||
])
|
||||
active.trips.forEach(t => upsertTrip(t))
|
||||
archived.trips.forEach(t => upsertTrip(t))
|
||||
return { trips: active.trips, archivedTrips: archived.trips }
|
||||
},
|
||||
async () => {
|
||||
const all = await offlineDb.trips.toArray()
|
||||
return {
|
||||
trips: all.filter(t => !t.is_archived),
|
||||
archivedTrips: all.filter(t => t.is_archived),
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async get(tripId: number | string): Promise<{ trip: Trip }> {
|
||||
if (!navigator.onLine) {
|
||||
const cached = await offlineDb.trips.get(Number(tripId))
|
||||
if (cached) return { trip: cached }
|
||||
throw new Error('No cached trip data available offline')
|
||||
}
|
||||
const result = await tripsApi.get(tripId)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
return onlineThenCache(
|
||||
async () => {
|
||||
const result = await tripsApi.get(tripId)
|
||||
upsertTrip(result.trip)
|
||||
return result
|
||||
},
|
||||
async () => {
|
||||
const cached = await offlineDb.trips.get(Number(tripId))
|
||||
if (cached) return { trip: cached }
|
||||
throw new Error('No cached trip data available offline')
|
||||
},
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* True when an error means the request never reached the server — a network-level
|
||||
* failure (offline, captive portal, proxy auth wall, dropped connection, CORS).
|
||||
* Axios sets `response` only when the server actually replied; its absence (on an
|
||||
* Axios error) means we never got one. A real HTTP error (4xx/5xx) HAS a response
|
||||
* and must NOT be treated as a network failure — the server spoke, so the caller
|
||||
* needs to see it. Non-Axios errors are surfaced too.
|
||||
*/
|
||||
function isNetworkError(err: unknown): boolean {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown } | null
|
||||
return !!e && e.isAxiosError === true && e.response == null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-through cache pattern shared by every repo's read methods.
|
||||
*
|
||||
* Reads degrade to the local Dexie cache in two situations:
|
||||
* 1. The browser reports it is offline (`navigator.onLine` false) — skip the
|
||||
* doomed request entirely.
|
||||
* 2. The browser *thinks* it is online but the request fails at the network
|
||||
* level — a lying `navigator.onLine` on a captive portal, a dropped
|
||||
* connection (H2). Rather than surfacing that (which blanks the trip even
|
||||
* though a good cached copy exists), we fall back to the cache.
|
||||
*
|
||||
* We intentionally gate only on `navigator.onLine`, NOT the connectivity probe:
|
||||
* the probe is a coarse global flag, and a single failed health check would
|
||||
* otherwise force every read to the (possibly empty) cache even when the request
|
||||
* itself would succeed. The network-error catch below covers the captive-portal
|
||||
* case the probe was meant to.
|
||||
*
|
||||
* A genuine HTTP error (404/403/500 — the server responded) is NOT swallowed: it
|
||||
* is rethrown so callers can set error state, navigate away, etc.
|
||||
*
|
||||
* Writes must NOT use this — they go through the mutation queue so failures are
|
||||
* surfaced and retried, not silently swallowed.
|
||||
*/
|
||||
export async function onlineThenCache<T>(
|
||||
onlineFn: () => Promise<T>,
|
||||
cacheFn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!navigator.onLine) return cacheFn()
|
||||
try {
|
||||
return await onlineFn()
|
||||
} catch (err) {
|
||||
if (isNetworkError(err)) return cacheFn()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@ import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import { tripSyncManager } from '../sync/tripSyncManager'
|
||||
import { clearAll } from '../db/offlineDb'
|
||||
import { reopenForUser, deleteCurrentUserDb } from '../db/offlineDb'
|
||||
import { setAuthed } from '../sync/authGate'
|
||||
import { unregisterSyncTriggers } from '../sync/syncTriggers'
|
||||
import { useSystemNoticeStore } from './systemNoticeStore.js'
|
||||
|
||||
interface AuthResponse {
|
||||
@@ -37,10 +39,10 @@ interface AuthState {
|
||||
placesAutocompleteEnabled: boolean
|
||||
placesDetailsEnabled: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string) => Promise<AuthResponse>
|
||||
login: (email: string, password: string, rememberMe?: boolean) => Promise<LoginResult>
|
||||
completeMfaLogin: (mfaToken: string, code: string, rememberMe?: boolean) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string, invite_token?: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
/** Pass `{ silent: true }` to refresh the user without toggling global isLoading (avoids unmounting protected routes). */
|
||||
loadUser: (opts?: { silent?: boolean }) => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
@@ -65,6 +67,19 @@ interface AuthState {
|
||||
// Sequence counter to prevent stale loadUser responses from overwriting fresh auth state
|
||||
let authSequence = 0
|
||||
|
||||
/**
|
||||
* Mark the session authenticated and point the offline DB at this user's scoped
|
||||
* database before any background sync runs, so cached data never crosses users.
|
||||
*/
|
||||
async function onAuthSuccess(userId: number): Promise<void> {
|
||||
setAuthed(true)
|
||||
try {
|
||||
await reopenForUser(userId)
|
||||
} catch (err) {
|
||||
console.error('[auth] failed to open user-scoped offline DB', err)
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -84,11 +99,11 @@ export const useAuthStore = create<AuthState>()(
|
||||
placesAutocompleteEnabled: true,
|
||||
placesDetailsEnabled: true,
|
||||
|
||||
login: async (email: string, password: string) => {
|
||||
login: async (email: string, password: string, rememberMe?: boolean) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
const data = await authApi.login({ email, password, remember_me: rememberMe }) as AuthResponse & { mfa_required?: boolean; mfa_token?: string }
|
||||
if (data.mfa_required && data.mfa_token) {
|
||||
set({ isLoading: false, error: null })
|
||||
return { mfa_required: true as const, mfa_token: data.mfa_token }
|
||||
@@ -99,6 +114,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -112,17 +128,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
completeMfaLogin: async (mfaToken: string, code: string) => {
|
||||
completeMfaLogin: async (mfaToken: string, code: string, rememberMe?: boolean) => {
|
||||
authSequence++
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, '') })
|
||||
const data = await authApi.verifyMfaLogin({ mfa_token: mfaToken, code: code.replace(/\s/g, ''), remember_me: rememberMe })
|
||||
set({
|
||||
user: data.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
if (!data.user?.must_change_password) {
|
||||
@@ -147,6 +164,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
useSystemNoticeStore.getState().fetch()
|
||||
@@ -158,18 +176,27 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
logout: async () => {
|
||||
// 1. Gate first so any in-flight flush/syncAll bails before we wipe the DB.
|
||||
setAuthed(false)
|
||||
set({ isAuthenticated: false })
|
||||
// 2. Stop background sync triggers (30s interval, WS pre-reconnect hook, listeners).
|
||||
unregisterSyncTriggers()
|
||||
// 3. Tear down the live connection.
|
||||
disconnect()
|
||||
useSystemNoticeStore.getState().reset()
|
||||
// Tell server to clear the httpOnly cookie
|
||||
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// Clear service worker caches containing sensitive data
|
||||
// 4. Tell server to clear the httpOnly cookie (best-effort).
|
||||
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
// 5. Clear service worker caches containing sensitive data.
|
||||
if ('caches' in window) {
|
||||
caches.delete('api-data').catch(() => {})
|
||||
caches.delete('user-uploads').catch(() => {})
|
||||
await Promise.all([
|
||||
caches.delete('api-data').catch(() => {}),
|
||||
caches.delete('user-uploads').catch(() => {}),
|
||||
])
|
||||
}
|
||||
// Purge all cached trip data from IndexedDB
|
||||
clearAll().catch(console.error)
|
||||
// 6. Delete this user's scoped IndexedDB and return to the anonymous DB.
|
||||
await deleteCurrentUserDb().catch(console.error)
|
||||
// 7. Finish clearing auth state.
|
||||
set({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
@@ -189,6 +216,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
} catch (err: unknown) {
|
||||
if (seq !== authSequence) return // stale response — ignore
|
||||
@@ -282,6 +310,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
demoMode: true,
|
||||
error: null,
|
||||
})
|
||||
await onAuthSuccess(data.user.id)
|
||||
connect()
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
|
||||
@@ -193,25 +193,34 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
|
||||
// Assignments
|
||||
case 'assignment:created': {
|
||||
const dayKey = String((payload.assignment as Assignment).day_id)
|
||||
const existing = (state.assignments[dayKey] || [])
|
||||
const placeId = (payload.assignment as Assignment).place?.id || (payload.assignment as Assignment).place_id
|
||||
if (existing.some(a => a.id === (payload.assignment as Assignment).id || (placeId && a.place?.id === placeId))) {
|
||||
const hasTempVersion = existing.some(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (hasTempVersion) {
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: existing.map(a => (a.id < 0 && a.place?.id === placeId) ? payload.assignment as Assignment : a),
|
||||
}
|
||||
}
|
||||
const incoming = payload.assignment as Assignment
|
||||
const dayKey = String(incoming.day_id)
|
||||
const existing = state.assignments[dayKey] || []
|
||||
const placeId = incoming.place?.id ?? incoming.place_id
|
||||
|
||||
// Already have this exact assignment id → duplicate broadcast or the
|
||||
// echo of an already-committed assignment. No-op.
|
||||
if (existing.some(a => a.id === incoming.id)) return {}
|
||||
|
||||
// Reconcile our own optimistic create: replace the temp (negative-id)
|
||||
// assignment of the same place on this day with the real one. Guarded on
|
||||
// a real placeId so an assignment with no place can never collapse onto
|
||||
// another place-less one (undefined === undefined).
|
||||
if (placeId != null) {
|
||||
const tempIdx = existing.findIndex(a => a.id < 0 && a.place?.id === placeId)
|
||||
if (tempIdx !== -1) {
|
||||
const next = existing.slice()
|
||||
next[tempIdx] = incoming
|
||||
return { assignments: { ...state.assignments, [dayKey]: next } }
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// Genuinely new — including a legitimate second assignment of a place
|
||||
// already on this day (no temp version to reconcile). Append.
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[dayKey]: [...existing, payload.assignment as Assignment],
|
||||
[dayKey]: [...existing, incoming],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ import { dayRepo } from '../repo/dayRepo'
|
||||
import { placeRepo } from '../repo/placeRepo'
|
||||
import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { budgetRepo } from '../repo/budgetRepo'
|
||||
import { reservationRepo } from '../repo/reservationRepo'
|
||||
import { fileRepo } from '../repo/fileRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDaysSlice } from './slices/daysSlice'
|
||||
@@ -61,7 +64,9 @@ export interface TripStoreState
|
||||
|
||||
setSelectedDay: (dayId: number | null) => void
|
||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||
resetTrip: () => void
|
||||
loadTrip: (tripId: number | string) => Promise<void>
|
||||
hydrateActiveTrip: (tripId: number | string) => Promise<void>
|
||||
refreshDays: (tripId: number | string) => Promise<void>
|
||||
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
||||
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
|
||||
@@ -89,15 +94,40 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
handleRemoteEvent: (event: WebSocketEvent) => handleRemoteEvent(set, get, event),
|
||||
|
||||
// Clear every trip-scoped slice so switching trips (or losing access to one)
|
||||
// can never leave a previous trip's data visible. Global tags/categories are
|
||||
// left intact. Called at the top of loadTrip.
|
||||
resetTrip: () => set({
|
||||
trip: null,
|
||||
days: [],
|
||||
places: [],
|
||||
assignments: {},
|
||||
dayNotes: {},
|
||||
packingItems: [],
|
||||
todoItems: [],
|
||||
budgetItems: [],
|
||||
files: [],
|
||||
reservations: [],
|
||||
selectedDayId: null,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
loadTrip: async (tripId: number | string) => {
|
||||
get().resetTrip()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const [tripData, daysData, placesData, packingData, todoData, tagsData, categoriesData] = await Promise.all([
|
||||
const [tripData, daysData, placesData, packingData, todoData, budgetData, reservationsData, filesData, tagsData, categoriesData] = await Promise.all([
|
||||
tripRepo.get(tripId),
|
||||
dayRepo.list(tripId),
|
||||
placeRepo.list(tripId),
|
||||
packingRepo.list(tripId),
|
||||
todoRepo.list(tripId),
|
||||
// Budget / reservations / files are hydrated here too so the offline
|
||||
// path is uniform (no separate tab-gated effects). Non-fatal: a failure
|
||||
// in any of these must not blank the whole trip.
|
||||
budgetRepo.list(tripId).catch(() => ({ items: [] as BudgetItem[] })),
|
||||
reservationRepo.list(tripId).catch(() => ({ reservations: [] as Reservation[] })),
|
||||
fileRepo.list(tripId).catch(() => ({ files: [] as TripFile[] })),
|
||||
navigator.onLine
|
||||
? tagsApi.list().catch(() => offlineDb.tags.toArray().then(tags => ({ tags })))
|
||||
: offlineDb.tags.toArray().then(tags => ({ tags })),
|
||||
@@ -121,6 +151,9 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
dayNotes: dayNotesMap,
|
||||
packingItems: packingData.items,
|
||||
todoItems: todoData.items,
|
||||
budgetItems: budgetData.items,
|
||||
reservations: reservationsData.reservations,
|
||||
files: filesData.files,
|
||||
tags: tagsData.tags,
|
||||
categories: categoriesData.categories,
|
||||
isLoading: false,
|
||||
@@ -132,6 +165,22 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
// Silently re-fetch the active trip's collaborative state into the store after
|
||||
// the network comes back (WS reconnect or `online` event) so edits missed while
|
||||
// offline appear in place — no splash, no resetTrip. Each resource is
|
||||
// best-effort; a failure on one must not wipe the others.
|
||||
hydrateActiveTrip: async (tripId: number | string) => {
|
||||
await Promise.all([
|
||||
get().refreshDays(tripId),
|
||||
placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}),
|
||||
packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}),
|
||||
todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}),
|
||||
get().loadBudgetItems(tripId),
|
||||
get().loadReservations(tripId),
|
||||
get().loadFiles(tripId),
|
||||
])
|
||||
},
|
||||
|
||||
refreshDays: async (tripId: number | string) => {
|
||||
try {
|
||||
const daysData = await dayRepo.list(tripId)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Auth gate — a single boolean the sync layer checks before touching the
|
||||
* offline DB. It lets logout disable all background sync (flush / syncAll /
|
||||
* periodic triggers) *before* awaiting the DB swap, so an in-flight loop can't
|
||||
* re-seed the database after the user has logged out.
|
||||
*
|
||||
* Kept separate from authStore to avoid an import cycle
|
||||
* (authStore → tripSyncManager → authStore).
|
||||
*/
|
||||
let _authed = false
|
||||
|
||||
export function setAuthed(value: boolean): void {
|
||||
_authed = value
|
||||
}
|
||||
|
||||
export function isAuthed(): boolean {
|
||||
return _authed
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
import { offlineDb } from '../db/offlineDb'
|
||||
import { apiClient } from '../api/client'
|
||||
import { isAuthed } from './authGate'
|
||||
import type { QueuedMutation } from '../db/offlineDb'
|
||||
import type { Table } from 'dexie'
|
||||
|
||||
@@ -39,6 +40,27 @@ let _flushing = false
|
||||
// Monotonically increasing timestamp so same-millisecond enqueues
|
||||
// still get a deterministic FIFO order when sorted by createdAt.
|
||||
let _lastTs = 0
|
||||
// Monotonic counter for offline temp ids. Date.now() alone collides when two
|
||||
// creates land in the same millisecond (bulk import, rapid tapping), which would
|
||||
// overwrite one optimistic Dexie row. This guarantees distinct negative ids.
|
||||
let _lastTempId = 0
|
||||
|
||||
/**
|
||||
* Mint a collision-free temporary (negative) id for an offline-created entity.
|
||||
* Monotonic across the session so same-millisecond creates never collide.
|
||||
*/
|
||||
export function nextTempId(): number {
|
||||
const now = Date.now()
|
||||
_lastTempId = now > _lastTempId ? now : _lastTempId + 1
|
||||
return -_lastTempId
|
||||
}
|
||||
|
||||
/** HTTP statuses that should be retried later rather than treated as terminal. */
|
||||
function isRetryableStatus(status: number | undefined): boolean {
|
||||
// 401: token expired mid-flush (offline window) — retry after re-auth.
|
||||
// 408/425/429: timeout / too-early / rate-limited — transient.
|
||||
return status === 401 || status === 408 || status === 425 || status === 429
|
||||
}
|
||||
|
||||
export const mutationQueue = {
|
||||
/**
|
||||
@@ -67,8 +89,12 @@ export const mutationQueue = {
|
||||
* 4xx responses are marked failed and skipped.
|
||||
*/
|
||||
async flush(): Promise<void> {
|
||||
if (_flushing || !navigator.onLine) return
|
||||
if (_flushing || !navigator.onLine || !isAuthed()) return
|
||||
_flushing = true
|
||||
// tempId → realId learned during this flush, so a dependent edit/delete
|
||||
// queued against an offline-created entity (still holding the negative id)
|
||||
// can be rewritten to the server id before it is replayed.
|
||||
const idMap = new Map<number, number>()
|
||||
try {
|
||||
const pending = await offlineDb.mutationQueue
|
||||
.where('status')
|
||||
@@ -79,10 +105,32 @@ export const mutationQueue = {
|
||||
// Mark as syncing so UI can show progress
|
||||
await offlineDb.mutationQueue.update(mutation.id, { status: 'syncing' })
|
||||
|
||||
// Resolve a temp-id reference now that earlier CREATEs in this flush
|
||||
// may have completed (FIFO order guarantees the CREATE ran first).
|
||||
let reqUrl = mutation.url
|
||||
let reqEntityId = mutation.entityId
|
||||
if (mutation.tempEntityId !== undefined) {
|
||||
const realId = idMap.get(mutation.tempEntityId)
|
||||
if (realId !== undefined) {
|
||||
reqUrl = reqUrl.replace('{id}', String(realId))
|
||||
reqEntityId = realId
|
||||
}
|
||||
}
|
||||
// Placeholder still unresolved → the create it depended on is gone
|
||||
// (failed or missing). Surface it as failed rather than firing a 404.
|
||||
if (reqUrl.includes('{id}')) {
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'failed',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: 'unresolved temp id (dependent create did not sync)',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.request({
|
||||
method: mutation.method,
|
||||
url: mutation.url,
|
||||
url: reqUrl,
|
||||
data: mutation.body,
|
||||
headers: { 'X-Idempotency-Key': mutation.id },
|
||||
})
|
||||
@@ -95,31 +143,51 @@ export const mutationQueue = {
|
||||
const values = Object.values(response.data as Record<string, unknown>)
|
||||
const entity = values[0]
|
||||
if (entity && typeof entity === 'object' && 'id' in entity) {
|
||||
// Remove temp optimistic entry if id changed (CREATE case)
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== (entity as { id: number }).id) {
|
||||
const realId = (entity as { id: number }).id
|
||||
// Remove temp optimistic entry if id changed (CREATE case) and
|
||||
// remap any queued mutations that still target the negative id.
|
||||
if (mutation.tempId !== undefined && mutation.tempId !== realId) {
|
||||
await table.delete(mutation.tempId)
|
||||
idMap.set(mutation.tempId, realId)
|
||||
// Durable rewrite so dependents survive a flush boundary / reload.
|
||||
await offlineDb.mutationQueue
|
||||
.where('tripId')
|
||||
.equals(mutation.tripId)
|
||||
.filter(m => m.tempEntityId === mutation.tempId)
|
||||
.modify(m => {
|
||||
m.url = m.url.replace('{id}', String(realId))
|
||||
m.entityId = realId
|
||||
m.tempEntityId = undefined
|
||||
})
|
||||
}
|
||||
await table.put(entity)
|
||||
}
|
||||
}
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && mutation.entityId !== undefined) {
|
||||
} else if (mutation.method === 'DELETE' && mutation.resource && reqEntityId !== undefined) {
|
||||
// DELETE was already applied optimistically; ensure it's gone
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.entityId)
|
||||
if (table) await table.delete(reqEntityId)
|
||||
}
|
||||
|
||||
await offlineDb.mutationQueue.delete(mutation.id)
|
||||
} catch (err: unknown) {
|
||||
const httpStatus = (err as { response?: { status: number } })?.response?.status
|
||||
if (httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500) {
|
||||
// Permanent client error — mark failed, continue with next
|
||||
const isTerminal =
|
||||
httpStatus !== undefined && httpStatus >= 400 && httpStatus < 500 && !isRetryableStatus(httpStatus)
|
||||
if (isTerminal) {
|
||||
// Permanent client error — roll back the phantom optimistic CREATE so
|
||||
// it can't masquerade as synced, then mark failed and continue.
|
||||
if (mutation.method !== 'DELETE' && mutation.tempId !== undefined && mutation.resource) {
|
||||
const table = getTable(mutation.resource)
|
||||
if (table) await table.delete(mutation.tempId)
|
||||
}
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'failed',
|
||||
attempts: mutation.attempts + 1,
|
||||
lastError: String(err),
|
||||
})
|
||||
} else {
|
||||
// Network error — reset to pending, abort flush (retry on next trigger)
|
||||
// Network / transient error — reset to pending, abort flush (retry next trigger)
|
||||
await offlineDb.mutationQueue.update(mutation.id, {
|
||||
status: 'pending',
|
||||
attempts: mutation.attempts + 1,
|
||||
@@ -160,9 +228,19 @@ export const mutationQueue = {
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag and timestamp counter — useful in tests. */
|
||||
/** Count permanently-failed mutations (surfaced separately so the user knows
|
||||
* changes were dropped — they are NOT folded into pendingCount). */
|
||||
async failedCount(): Promise<number> {
|
||||
return offlineDb.mutationQueue
|
||||
.where('status')
|
||||
.equals('failed')
|
||||
.count()
|
||||
},
|
||||
|
||||
/** Reset internal flushing flag and timestamp counters — useful in tests. */
|
||||
_resetFlushing(): void {
|
||||
_flushing = false
|
||||
_lastTs = 0
|
||||
_lastTempId = 0
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Ask the browser for persistent storage so our offline data — prefetched map
|
||||
* tiles, cached file blobs, the IndexedDB caches — is exempt from eviction under
|
||||
* storage pressure. Without this the browser may purge tiles right when a
|
||||
* traveler goes offline and needs them (audit H8 / M6).
|
||||
*
|
||||
* Best-effort and idempotent: returns whether persistence is (now) granted.
|
||||
*/
|
||||
export async function requestPersistentStorage(): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator === 'undefined' || !navigator.storage?.persist) return false
|
||||
// Already persisted? Avoid re-prompting where the API distinguishes.
|
||||
if (navigator.storage.persisted && (await navigator.storage.persisted())) return true
|
||||
return await navigator.storage.persist()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -14,17 +14,34 @@
|
||||
*/
|
||||
import { mutationQueue } from './mutationQueue'
|
||||
import { tripSyncManager } from './tripSyncManager'
|
||||
import { setPreReconnectHook } from '../api/websocket'
|
||||
import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
|
||||
const PERIODIC_MS = 30_000
|
||||
|
||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let _registered = false
|
||||
|
||||
/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */
|
||||
/** Pull the latest server state for every open trip into the Zustand store. */
|
||||
function rehydrateActiveTrips() {
|
||||
const store = useTripStore.getState()
|
||||
for (const tripId of getActiveTrips()) {
|
||||
store.hydrateActiveTrip(tripId).catch(console.error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network came back — flush local writes first, then re-seed Dexie for all
|
||||
* cacheable trips and re-hydrate the open trip's store so a collaborator's
|
||||
* edits made while we were offline appear without navigating away.
|
||||
*/
|
||||
function onOnline() {
|
||||
mutationQueue.flush().catch(console.error)
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
mutationQueue.flush()
|
||||
.catch(console.error)
|
||||
.finally(() => {
|
||||
tripSyncManager.syncAll().catch(console.error)
|
||||
rehydrateActiveTrips()
|
||||
})
|
||||
}
|
||||
|
||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
||||
@@ -48,6 +65,11 @@ export function registerSyncTriggers(): void {
|
||||
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||
// limiters when the socket drops and reconnects while the device is online.
|
||||
setPreReconnectHook(() => mutationQueue.flush())
|
||||
// After the reconnect flush, pull canonical state for the open trip back into
|
||||
// the store (the WS layer awaits the flush hook before invoking this).
|
||||
setRefetchCallback(tripId => {
|
||||
useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error)
|
||||
})
|
||||
|
||||
window.addEventListener('online', onOnline)
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
@@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void {
|
||||
_registered = false
|
||||
|
||||
setPreReconnectHook(null)
|
||||
setRefetchCallback(null)
|
||||
window.removeEventListener('online', onOnline)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
if (_intervalId !== null) {
|
||||
|
||||
@@ -17,11 +17,18 @@ import { offlineDb, upsertSyncMeta } from '../db/offlineDb'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Estimated average tile size in KB (road/transit tiles ~15 KB). */
|
||||
/** Estimated average tile size in KB (raster basemap tiles ~15 KB). */
|
||||
const AVG_TILE_KB = 15
|
||||
|
||||
/** Hard cap: ~50 MB worth of tiles. */
|
||||
export const MAX_TILES = Math.floor((50 * 1024) / AVG_TILE_KB) // ≈ 3413
|
||||
/**
|
||||
* Hard cap on prefetched tiles (~180 MB).
|
||||
*
|
||||
* MUST stay in sync with the Workbox 'map-tiles' `maxEntries` in
|
||||
* client/vite.config.js (kept equal). If this budget exceeds the SW cache size,
|
||||
* the LRU evicts freshly-prefetched tiles on arrival and the offline map goes
|
||||
* blank — which is exactly the bug this value was raised (from ~3413) to fix.
|
||||
*/
|
||||
export const MAX_TILES = Math.floor((180 * 1024) / AVG_TILE_KB) // = 12288
|
||||
|
||||
const DEFAULT_TILE_URL =
|
||||
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
|
||||
@@ -177,15 +184,16 @@ export async function prefetchTilesForTrip(
|
||||
const bbox = computeBbox(places)
|
||||
if (!bbox) return
|
||||
|
||||
// Size guard: if total tile count across all zooms exceeds cap, skip
|
||||
const estimated = countTiles(bbox, 10, 16)
|
||||
if (estimated > MAX_TILES) {
|
||||
console.warn(
|
||||
`[tilePrefetch] trip ${tripId}: estimated ${estimated} tiles exceeds cap (${MAX_TILES}), skipping`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Zoom-clamp rather than skip: prefetchTiles fills zooms low→high and stops
|
||||
// once MAX_TILES is reached, so large (region / road-trip) bboxes still get
|
||||
// their lower zooms cached instead of being skipped entirely.
|
||||
//
|
||||
// NOTE: opaque (no-cors) tile responses are padded by Chromium to ~7 MB each
|
||||
// for quota accounting, so the real on-disk budget is far below 180 MB. We
|
||||
// keep no-cors deliberately: switching to cors would break self-hosted/custom
|
||||
// tile providers that don't send CORS headers. To stop the browser evicting
|
||||
// these tiles under the inflated quota, we request persistent storage at app
|
||||
// init instead (sync/persistentStorage.ts).
|
||||
const fetched = await prefetchTiles(bbox, template)
|
||||
|
||||
// Update syncMeta with bbox and tile count
|
||||
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
upsertCategories,
|
||||
upsertSyncMeta,
|
||||
clearTripData,
|
||||
enforceBlobBudget,
|
||||
} from '../db/offlineDb'
|
||||
import { prefetchTilesForTrip } from './tilePrefetcher'
|
||||
import { isAuthed } from './authGate'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember } from '../types'
|
||||
|
||||
@@ -108,13 +110,16 @@ async function cacheFilesForTrip(files: TripFile[]): Promise<void> {
|
||||
const resp = await fetch(file.url!, { credentials: 'include' })
|
||||
if (!resp.ok) continue
|
||||
const blob = await resp.blob()
|
||||
await offlineDb.blobCache.put({ url: file.url!, blob, mime: file.mime_type, cachedAt: Date.now() })
|
||||
await offlineDb.blobCache.put({ url: file.url!, tripId: file.trip_id, blob, bytes: blob.size, mime: file.mime_type, cachedAt: Date.now() })
|
||||
cached++
|
||||
} catch {
|
||||
// Network failure — skip this file, will retry next sync
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the blob cache within its size/count budget after adding new files.
|
||||
if (cached > 0) await enforceBlobBudget().catch(() => {})
|
||||
|
||||
// Update filesCachedCount in syncMeta
|
||||
const tripId = files[0]?.trip_id
|
||||
if (tripId) {
|
||||
@@ -134,7 +139,7 @@ export const tripSyncManager = {
|
||||
* No-ops when offline.
|
||||
*/
|
||||
async syncAll(): Promise<void> {
|
||||
if (_syncing || !navigator.onLine) return
|
||||
if (_syncing || !navigator.onLine || !isAuthed()) return
|
||||
_syncing = true
|
||||
try {
|
||||
const { trips } = await tripsApi.list() as { trips: Trip[] }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
import { parseTimeToMinutes, getSpanPhase, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
|
||||
|
||||
describe('parseTimeToMinutes', () => {
|
||||
it('parses HH:MM string', () => {
|
||||
@@ -34,6 +34,38 @@ describe('getSpanPhase', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getTransportRouteEndpoints', () => {
|
||||
const pickup = { role: 'from', lat: 48.1, lng: 11.5 }
|
||||
const dropoff = { role: 'to', lat: 52.5, lng: 13.4 }
|
||||
// A car rental spanning day 1 (pickup) through day 3 (drop-off).
|
||||
const rental = { day_id: 1, end_day_id: 3, endpoints: [pickup, dropoff] }
|
||||
|
||||
it('routes to the pickup only on the start day of a multi-day rental', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 1)).toEqual({ from: { lat: 48.1, lng: 11.5 }, to: null })
|
||||
})
|
||||
|
||||
it('routes from the drop-off only on the end day', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 3)).toEqual({ from: null, to: { lat: 52.5, lng: 13.4 } })
|
||||
})
|
||||
|
||||
it('adds no waypoints on the days in between (regression for #1210)', () => {
|
||||
expect(getTransportRouteEndpoints(rental, 2)).toEqual({ from: null, to: null })
|
||||
})
|
||||
|
||||
it('uses both endpoints for a single-day transport', () => {
|
||||
const sameDay = { day_id: 1, end_day_id: 1, endpoints: [pickup, dropoff] }
|
||||
expect(getTransportRouteEndpoints(sameDay, 1)).toEqual({
|
||||
from: { lat: 48.1, lng: 11.5 },
|
||||
to: { lat: 52.5, lng: 13.4 },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns nulls when the endpoints carry no coordinates', () => {
|
||||
const noCoords = { day_id: 1, end_day_id: 1, endpoints: [{ role: 'from' }, { role: 'to' }] }
|
||||
expect(getTransportRouteEndpoints(noCoords, 1)).toEqual({ from: null, to: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDisplayTimeForDay', () => {
|
||||
const r = { day_id: 1, end_day_id: 3, reservation_time: '2025-01-01T09:00:00', reservation_end_time: '2025-01-03T14:00:00' }
|
||||
|
||||
|
||||
@@ -29,6 +29,33 @@ export function getSpanPhase(
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
/**
|
||||
* The route waypoints a transport contributes on a given day, respecting multi-day spans.
|
||||
* A car rental (or any reservation whose span covers several days) is only routed to on its
|
||||
* pickup day (the departure endpoint) and from on its drop-off day (the arrival endpoint) — on
|
||||
* the days in between you simply hold the vehicle, so it adds no waypoints and must not pull the
|
||||
* route to those points. Single-day transports contribute both endpoints.
|
||||
*/
|
||||
export function getTransportRouteEndpoints(
|
||||
r: any,
|
||||
dayId: number
|
||||
): { from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null } {
|
||||
const ep = (role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
switch (getSpanPhase(r, dayId)) {
|
||||
case 'start':
|
||||
return { from: ep('from'), to: null }
|
||||
case 'end':
|
||||
return { from: null, to: ep('to') }
|
||||
case 'middle':
|
||||
return { from: null, to: null }
|
||||
default:
|
||||
return { from: ep('from'), to: ep('to') }
|
||||
}
|
||||
}
|
||||
|
||||
export function getDisplayTimeForDay(
|
||||
r: { day_id?: number | null; end_day_id?: number | null; reservation_time?: string | null; reservation_end_time?: string | null },
|
||||
dayId: number
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { Day, Accommodation } from '../types'
|
||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors } from './dayOrder'
|
||||
import { getDayOrder, isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from './dayOrder'
|
||||
|
||||
const days = [
|
||||
{ id: 10, day_number: 1 },
|
||||
@@ -70,4 +70,51 @@ describe('getAccommodationAnchors', () => {
|
||||
const accs = [hotel({ start_day_id: 10, end_day_id: 30, place_lat: null, place_lng: null })]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps morning/evening correct on a transfer day when the morning stay runs long (#887)', () => {
|
||||
const accs = [
|
||||
hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 }), // slept here, checks out later
|
||||
hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 }), // check-in today
|
||||
]
|
||||
expect(getAccommodationAnchors(days[1], days, accs)).toEqual({
|
||||
start: { lat: 1, lng: 1 },
|
||||
end: { lat: 9, lng: 9 },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getDayBookendHotels', () => {
|
||||
it('returns nothing when the day has no accommodation', () => {
|
||||
expect(getDayBookendHotels(days[1], days, [])).toEqual({})
|
||||
})
|
||||
|
||||
it('bookends both ends with the single hotel on a normal stay day', () => {
|
||||
const h = hotel({ start_day_id: 10, end_day_id: 30 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [h])
|
||||
expect(morning).toBe(h)
|
||||
expect(evening).toBe(h)
|
||||
})
|
||||
|
||||
it('uses the checked-out hotel in the morning and the checked-in hotel in the evening on a transfer day', () => {
|
||||
const out = hotel({ start_day_id: 10, end_day_id: 20, place_lat: 1, place_lng: 1 })
|
||||
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [out, into])
|
||||
expect(morning).toBe(out)
|
||||
expect(evening).toBe(into)
|
||||
})
|
||||
|
||||
it('still picks the slept-in hotel for the morning when its stay does not end on the transfer day (#887)', () => {
|
||||
// The morning hotel runs long (checks out day 3) so it is not flagged as "checks out today";
|
||||
// the old "ends today" rule collapsed both bookends onto the arriving hotel.
|
||||
const stayed = hotel({ start_day_id: 10, end_day_id: 30, place_lat: 1, place_lng: 1 })
|
||||
const into = hotel({ start_day_id: 20, end_day_id: 30, place_lat: 9, place_lng: 9 })
|
||||
const { morning, evening } = getDayBookendHotels(days[1], days, [stayed, into])
|
||||
expect(morning).toBe(stayed)
|
||||
expect(evening).toBe(into)
|
||||
})
|
||||
|
||||
it('ignores accommodations without coordinates', () => {
|
||||
const h = hotel({ place_lat: null, place_lng: null })
|
||||
expect(getDayBookendHotels(days[1], days, [h])).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,36 @@ import type { Day, Accommodation, RouteAnchors } from '../types'
|
||||
export const getDayOrder = (day: Day, days: Day[]): number =>
|
||||
day.day_number ?? days.indexOf(day)
|
||||
|
||||
// The two hotels that bookend a day: the one you woke up in (morning) and the one you sleep in
|
||||
// tonight (evening). On a transfer day these differ; on any other day both are the single hotel.
|
||||
// The morning hotel is keyed off "checked in on an earlier day and still in range" (i.e. you slept
|
||||
// there) rather than "checks out today", so it stays correct when an overlapping or long stay does
|
||||
// not end exactly on the transfer day.
|
||||
export const getDayBookendHotels = (
|
||||
day: Day,
|
||||
days: Day[],
|
||||
accommodations: Accommodation[],
|
||||
): { morning?: Accommodation; evening?: Accommodation } => {
|
||||
const inRange = accommodations.filter(a =>
|
||||
a.place_lat != null && a.place_lng != null &&
|
||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||
)
|
||||
if (inRange.length === 0) return {}
|
||||
|
||||
const dayOrd = getDayOrder(day, days)
|
||||
const orderOf = (id: number) => {
|
||||
const d = days.find(x => x.id === id)
|
||||
return d ? getDayOrder(d, days) : dayOrd
|
||||
}
|
||||
const checkIn = inRange.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||
const sleptHere = inRange.find(a => orderOf(a.start_day_id) < dayOrd) // the hotel you woke up in
|
||||
|
||||
return {
|
||||
morning: sleptHere ?? checkIn ?? inRange[0],
|
||||
evening: checkIn ?? sleptHere ?? inRange[0],
|
||||
}
|
||||
}
|
||||
|
||||
// Derives route anchors from the accommodation(s) active on a day. A single hotel is the day's home
|
||||
// base, so the route is a loop that starts and ends there. A transfer day — checking out of one hotel
|
||||
// and into another — instead runs from the morning hotel to the evening one.
|
||||
@@ -11,22 +41,12 @@ export const getAccommodationAnchors = (
|
||||
days: Day[],
|
||||
accommodations: Accommodation[],
|
||||
): RouteAnchors => {
|
||||
const located = accommodations.filter(a =>
|
||||
a.place_lat != null && a.place_lng != null &&
|
||||
isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days),
|
||||
)
|
||||
if (located.length === 0) return {}
|
||||
|
||||
const toAnchor = (a: Accommodation) => ({ lat: a.place_lat as number, lng: a.place_lng as number })
|
||||
|
||||
const checkOut = located.find(a => a.end_day_id === day.id) // the hotel you leave this morning
|
||||
const checkIn = located.find(a => a.start_day_id === day.id) // the hotel you arrive at tonight
|
||||
if (checkOut && checkIn && checkOut !== checkIn) {
|
||||
return { start: toAnchor(checkOut), end: toAnchor(checkIn) }
|
||||
const { morning, evening } = getDayBookendHotels(day, days, accommodations)
|
||||
if (!morning || !evening) return {}
|
||||
return {
|
||||
start: { lat: morning.place_lat as number, lng: morning.place_lng as number },
|
||||
end: { lat: evening.place_lat as number, lng: evening.place_lng as number },
|
||||
}
|
||||
|
||||
const hotel = toAnchor(located[0])
|
||||
return { start: hotel, end: hotel }
|
||||
}
|
||||
|
||||
export const isDayInAccommodationRange = (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="node" />
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
upsertReservations,
|
||||
upsertTripFiles,
|
||||
upsertSyncMeta,
|
||||
reopenForUser,
|
||||
reopenAnonymous,
|
||||
deleteCurrentUserDb,
|
||||
enforceBlobBudget,
|
||||
type QueuedMutation,
|
||||
type SyncMeta,
|
||||
type BlobCacheEntry,
|
||||
@@ -81,6 +85,15 @@ const makePlace = (id: number, tripId = 1): Place => ({
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
const makeBlob = (url: string, tripId = 1, bytes = 10, cachedAt = 1): BlobCacheEntry => ({
|
||||
url,
|
||||
tripId,
|
||||
blob: new Blob(['x'.repeat(bytes)], { type: 'application/pdf' }),
|
||||
bytes,
|
||||
mime: 'application/pdf',
|
||||
cachedAt,
|
||||
});
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -220,7 +233,9 @@ describe('offlineDb — blobCache', () => {
|
||||
const blob = new Blob(['%PDF-1.4 test'], { type: 'application/pdf' });
|
||||
const entry: BlobCacheEntry = {
|
||||
url: '/api/files/99/download',
|
||||
tripId: 1,
|
||||
blob,
|
||||
bytes: blob.size,
|
||||
mime: 'application/pdf',
|
||||
cachedAt: Date.now(),
|
||||
};
|
||||
@@ -231,6 +246,49 @@ describe('offlineDb — blobCache', () => {
|
||||
expect(stored!.mime).toBe('application/pdf');
|
||||
expect(stored!.blob).toBeDefined();
|
||||
});
|
||||
|
||||
it('queries blobs by tripId index', async () => {
|
||||
await offlineDb.blobCache.bulkPut([
|
||||
makeBlob('/api/files/1/download', 1),
|
||||
makeBlob('/api/files/2/download', 1),
|
||||
makeBlob('/api/files/3/download', 2),
|
||||
]);
|
||||
const trip1 = await offlineDb.blobCache.where('tripId').equals(1).toArray();
|
||||
expect(trip1).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — enforceBlobBudget', () => {
|
||||
it('evicts oldest-by-cachedAt entries past the count budget', async () => {
|
||||
// 5 entries with strictly increasing cachedAt; cap to 3.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 10, i + 1));
|
||||
}
|
||||
await enforceBlobBudget(3, Infinity);
|
||||
|
||||
expect(await offlineDb.blobCache.count()).toBe(3);
|
||||
// Oldest two (cachedAt 1 and 2) are gone; newest survive.
|
||||
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||
expect(await offlineDb.blobCache.get('/api/files/1/download')).toBeUndefined();
|
||||
expect(await offlineDb.blobCache.get('/api/files/4/download')).toBeDefined();
|
||||
});
|
||||
|
||||
it('evicts oldest entries past the byte budget', async () => {
|
||||
// 3 entries of 100 bytes each; cap to 250 bytes → newest two (200) survive.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await offlineDb.blobCache.put(makeBlob(`/api/files/${i}/download`, 1, 100, i + 1));
|
||||
}
|
||||
await enforceBlobBudget(Infinity, 250);
|
||||
|
||||
expect(await offlineDb.blobCache.count()).toBe(2);
|
||||
expect(await offlineDb.blobCache.get('/api/files/0/download')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a no-op when already within budget', async () => {
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||
await enforceBlobBudget(10, Infinity);
|
||||
expect(await offlineDb.blobCache.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — clearTripData', () => {
|
||||
@@ -241,9 +299,12 @@ describe('offlineDb — clearTripData', () => {
|
||||
const item: PackingItem = { id: 5, trip_id: 1, name: 'Towel', category: null, checked: 0, sort_order: 0, quantity: 1 };
|
||||
await upsertPackingItems([item]);
|
||||
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/1/download', 1));
|
||||
|
||||
// Also add data for a different trip — should NOT be removed
|
||||
await upsertTrip(makeTrip(2));
|
||||
await upsertDays([makeDay(99, 2)]);
|
||||
await offlineDb.blobCache.put(makeBlob('/api/files/2/download', 2));
|
||||
|
||||
await clearTripData(1);
|
||||
|
||||
@@ -251,10 +312,12 @@ describe('offlineDb — clearTripData', () => {
|
||||
expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.packingItems.where('trip_id').equals(1).count()).toBe(0);
|
||||
expect(await offlineDb.blobCache.where('tripId').equals(1).count()).toBe(0);
|
||||
|
||||
// Trip 2 intact
|
||||
expect(await offlineDb.trips.get(2)).toBeDefined();
|
||||
expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1);
|
||||
expect(await offlineDb.blobCache.get('/api/files/2/download')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,3 +334,37 @@ describe('offlineDb — clearAll', () => {
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offlineDb — per-user scoping (B4)', () => {
|
||||
afterEach(async () => {
|
||||
// Leave the suite on the anonymous DB so other tests are unaffected.
|
||||
await reopenAnonymous();
|
||||
});
|
||||
|
||||
it('isolates one user\'s cached data from another', async () => {
|
||||
await reopenForUser(1);
|
||||
await upsertPlaces([makePlace(10, 1)]);
|
||||
expect(await offlineDb.places.count()).toBe(1);
|
||||
|
||||
// Switching users must not expose user 1's rows.
|
||||
await reopenForUser(2);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Switching back restores user 1's data (different physical DB).
|
||||
await reopenForUser(1);
|
||||
expect(await offlineDb.places.get(10)).toBeDefined();
|
||||
});
|
||||
|
||||
it('deleteCurrentUserDb wipes the user DB and returns to anonymous', async () => {
|
||||
await reopenForUser(5);
|
||||
await upsertPlaces([makePlace(20, 1)]);
|
||||
|
||||
await deleteCurrentUserDb();
|
||||
// Now on the anonymous DB — no user data.
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
|
||||
// Re-opening user 5 starts empty (DB was deleted, not just detached).
|
||||
await reopenForUser(5);
|
||||
expect(await offlineDb.places.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { resetAllStores } from '../../helpers/store';
|
||||
import { buildDay, buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { Assignment } from '../../../src/types';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
@@ -50,6 +51,58 @@ describe('remoteEventHandler > assignments', () => {
|
||||
expect(assignments['10'][0].id).toBe(500);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003b: a second assignment of an already-present place is NOT suppressed (H11)', () => {
|
||||
const place = buildPlace({ id: 55 });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
// A committed (positive-id) assignment of place 55 already on the day.
|
||||
assignments: { '10': [buildAssignment({ id: 100, day_id: 10, place, place_id: place.id })] },
|
||||
});
|
||||
// A legitimately new, distinct assignment of the same place arrives.
|
||||
const second = buildAssignment({ id: 300, day_id: 10, place, place_id: place.id });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: second });
|
||||
const { assignments } = useTripStore.getState();
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
expect(assignments['10'].map(a => a.id).sort((x, y) => x - y)).toEqual([100, 300]);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003c: temp reconciliation replaces only the matching place, not a sibling temp (H11)', () => {
|
||||
const place55 = buildPlace({ id: 55 });
|
||||
const place66 = buildPlace({ id: 66 });
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
assignments: {
|
||||
'10': [
|
||||
buildAssignment({ id: -1, day_id: 10, place: place55, place_id: 55 }),
|
||||
buildAssignment({ id: -2, day_id: 10, place: place66, place_id: 66 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
const real = buildAssignment({ id: 500, day_id: 10, place: place55, place_id: 55 });
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: real });
|
||||
const { assignments } = useTripStore.getState();
|
||||
const ids = assignments['10'].map(a => a.id);
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
expect(ids).toContain(500); // temp 55 reconciled to real
|
||||
expect(ids).toContain(-2); // sibling temp 66 untouched
|
||||
expect(ids).not.toContain(-1);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-003d: place-less assignments do not collapse onto each other (H11)', () => {
|
||||
// Defensive: a malformed event lacking place data must not let the
|
||||
// `place?.id === placeId` reconciliation match undefined === undefined.
|
||||
const placeless = (id: number): Assignment =>
|
||||
({ ...buildAssignment({ id, day_id: 10 }), place: undefined, place_id: undefined } as unknown as Assignment);
|
||||
useTripStore.setState({
|
||||
days: [buildDay({ id: 10 })],
|
||||
assignments: { '10': [placeless(-1)] },
|
||||
});
|
||||
useTripStore.getState().handleRemoteEvent({ type: 'assignment:created', assignment: placeless(700) });
|
||||
const { assignments } = useTripStore.getState();
|
||||
// No placeId → no reconcile; both survive as distinct rows (no collapse).
|
||||
expect(assignments['10']).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('FE-WSEVT-ASSIGN-004: assignment:updated merges updated data into correct day', () => {
|
||||
seedData();
|
||||
const updated = buildAssignment({ id: 100, day_id: 10, notes: 'Updated notes' });
|
||||
|
||||
@@ -64,6 +64,20 @@ describe('placeRepo.list', () => {
|
||||
const result = await placeRepo.list(99);
|
||||
expect(result.places).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('online but request fails — falls back to Dexie cache (captive portal)', async () => {
|
||||
// navigator.onLine lies "true" on a captive portal; the request throws.
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
await offlineDb.places.put(place);
|
||||
|
||||
server.use(
|
||||
http.get('/api/trips/1/places', () => HttpResponse.error()),
|
||||
);
|
||||
|
||||
const result = await placeRepo.list(1);
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].id).toBe(place.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeRepo.create', () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* onlineThenCache — the read-through fallback shared by every repo (H2).
|
||||
*
|
||||
* Branches:
|
||||
* - navigator offline → cache only (skip the request)
|
||||
* - online but the request fails at the network level → fall back to cache
|
||||
* - online but the server returns an HTTP error → rethrow (don't mask)
|
||||
* - online and the request succeeds → return it, skip cache
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { onlineThenCache } from '../../../src/repo/withOfflineFallback';
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('onlineThenCache', () => {
|
||||
it('returns the online result when online', async () => {
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('online');
|
||||
expect(online).toHaveBeenCalledOnce();
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads the cache without calling online when navigator is offline', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||
expect(online).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to the cache on a network-level failure (no HTTP response)', async () => {
|
||||
// Axios network error: the request never reached the server (captive portal).
|
||||
const netErr = Object.assign(new Error('Network Error'), { isAxiosError: true, response: undefined });
|
||||
const online = vi.fn().mockRejectedValue(netErr);
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
expect(await onlineThenCache(online, cache)).toBe('cache');
|
||||
expect(online).toHaveBeenCalledOnce();
|
||||
expect(cache).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('rethrows a genuine HTTP error (server responded) instead of masking it', async () => {
|
||||
// 404/403/500 mean the server replied — callers must see it, not a stale cache.
|
||||
const httpErr = Object.assign(new Error('Not Found'), { isAxiosError: true, response: { status: 404 } });
|
||||
const online = vi.fn().mockRejectedValue(httpErr);
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('Not Found');
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows a non-Axios error rather than swallowing it', async () => {
|
||||
const online = vi.fn().mockRejectedValue(new Error('bug'));
|
||||
const cache = vi.fn().mockResolvedValue('cache');
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('bug');
|
||||
expect(cache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates a cache error (e.g. nothing cached) when online also failed', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
const online = vi.fn().mockResolvedValue('online');
|
||||
const cache = vi.fn().mockRejectedValue(new Error('No cached data'));
|
||||
|
||||
await expect(onlineThenCache(online, cache)).rejects.toThrow('No cached data');
|
||||
});
|
||||
});
|
||||
@@ -105,10 +105,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-AUTH-006: logout', () => {
|
||||
it('calls disconnect() and clears user state', () => {
|
||||
it('calls disconnect() and clears user state', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
const state = useAuthStore.getState();
|
||||
|
||||
expect(disconnect).toHaveBeenCalledOnce();
|
||||
@@ -441,10 +441,10 @@ describe('authStore', () => {
|
||||
});
|
||||
|
||||
describe('FE-STORE-AUTH-PERSIST-001: logout resets persisted snapshot', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', () => {
|
||||
it('snapshot has isAuthenticated:false after logout (PWA offline will redirect to login)', async () => {
|
||||
useAuthStore.setState({ user: buildUser(), isAuthenticated: true });
|
||||
|
||||
useAuthStore.getState().logout();
|
||||
await useAuthStore.getState().logout();
|
||||
|
||||
const snapshot = JSON.parse(localStorage.getItem('trek_auth_snapshot') ?? '{}');
|
||||
expect(snapshot?.state?.isAuthenticated).toBe(false);
|
||||
|
||||
@@ -8,18 +8,22 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { mutationQueue, generateUUID } from '../../../src/sync/mutationQueue';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { mutationQueue, generateUUID, nextTempId } from '../../../src/sync/mutationQueue';
|
||||
import { offlineDb, clearAll } from '../../../src/db/offlineDb';
|
||||
import { placeRepo } from '../../../src/repo/placeRepo';
|
||||
import { buildPlace, buildPackingItem } from '../../helpers/factories';
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
mutationQueue._resetFlushing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
@@ -214,6 +218,25 @@ describe('mutationQueue.flush — offline guard', () => {
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('does nothing when logged out (auth gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
let called = false;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => {
|
||||
called = true;
|
||||
return HttpResponse.json({ place: buildPlace({ trip_id: 1 }) });
|
||||
}),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
expect(called).toBe(false);
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pending / pendingCount ────────────────────────────────────────────────────
|
||||
@@ -265,3 +288,177 @@ describe('mutationQueue.pendingCount', () => {
|
||||
expect(await mutationQueue.pendingCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutationQueue.failedCount', () => {
|
||||
it('counts only failed mutations (not pending/syncing)', async () => {
|
||||
const id1 = generateUUID();
|
||||
const id2 = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id: id1 }));
|
||||
await mutationQueue.enqueue(makeMutation({ id: id2 }));
|
||||
await offlineDb.mutationQueue.update(id2, { status: 'failed' });
|
||||
|
||||
expect(await mutationQueue.failedCount()).toBe(1);
|
||||
expect(await mutationQueue.pendingCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B2: collision-free temp ids ────────────────────────────────────────────────
|
||||
|
||||
describe('nextTempId (B2)', () => {
|
||||
it('returns distinct negative ids even within the same millisecond', () => {
|
||||
mutationQueue._resetFlushing();
|
||||
const a = nextTempId();
|
||||
const b = nextTempId();
|
||||
const c = nextTempId();
|
||||
expect(a).toBeLessThan(0);
|
||||
expect(new Set([a, b, c]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('two tight offline creates produce two distinct Dexie rows', async () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false });
|
||||
await placeRepo.create(1, { name: 'First' });
|
||||
await placeRepo.create(1, { name: 'Second' });
|
||||
|
||||
const rows = await offlineDb.places.where('trip_id').equals(1).toArray();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map(r => r.name).sort()).toEqual(['First', 'Second']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B1: temp-id → real-id remapping ─────────────────────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — temp-id remapping (B1)', () => {
|
||||
it('rewrites a dependent PUT/DELETE to the real id within one flush', async () => {
|
||||
const tempId = -1;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const createId = generateUUID();
|
||||
const putId = generateUUID();
|
||||
const deleteId = generateUUID();
|
||||
|
||||
await mutationQueue.enqueue({
|
||||
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: deleteId, tripId: 1, method: 'DELETE', url: '/trips/1/places/{id}',
|
||||
body: undefined, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
|
||||
const putUrls: string[] = [];
|
||||
const deleteUrls: string[] = [];
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42 }) })),
|
||||
http.put('/api/trips/1/places/:id', ({ params }) => { putUrls.push(String(params.id)); return HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 42, name: 'Edited' }) }); }),
|
||||
http.delete('/api/trips/1/places/:id', ({ params }) => { deleteUrls.push(String(params.id)); return HttpResponse.json({ success: true }); }),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(putUrls).toEqual(['42']);
|
||||
expect(deleteUrls).toEqual(['42']);
|
||||
expect(await mutationQueue.pendingCount()).toBe(0);
|
||||
expect(await mutationQueue.failedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('durably rewrites a still-queued dependent after the CREATE flushes alone', async () => {
|
||||
const tempId = -7;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const createId = generateUUID();
|
||||
const putId = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id: createId, tripId: 1, method: 'POST', url: '/trips/1/places',
|
||||
body: { name: 'Temp' }, resource: 'places', tempId,
|
||||
});
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: tempId, tempEntityId: tempId,
|
||||
});
|
||||
|
||||
// Only the CREATE succeeds this round; the PUT errors out (network) and stays queued.
|
||||
let putAttempts = 0;
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ place: buildPlace({ trip_id: 1, id: 88 }) })),
|
||||
http.put('/api/trips/1/places/:id', () => { putAttempts++; return HttpResponse.error(); }),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const queuedPut = await offlineDb.mutationQueue.get(putId);
|
||||
expect(queuedPut).toBeDefined();
|
||||
expect(queuedPut!.url).toBe('/trips/1/places/88');
|
||||
expect(queuedPut!.entityId).toBe(88);
|
||||
expect(queuedPut!.tempEntityId).toBeUndefined();
|
||||
expect(putAttempts).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('marks an orphaned dependent (placeholder never resolved) as failed', async () => {
|
||||
const putId = generateUUID();
|
||||
await mutationQueue.enqueue({
|
||||
id: putId, tripId: 1, method: 'PUT', url: '/trips/1/places/{id}',
|
||||
body: { name: 'Edited' }, resource: 'places', entityId: -999, tempEntityId: -999,
|
||||
});
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(putId);
|
||||
expect(m!.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── B3: terminal rollback + retryable classification ────────────────────────────
|
||||
|
||||
describe('mutationQueue.flush — failure handling (B3)', () => {
|
||||
it('rolls back the phantom optimistic row on a terminal 400 CREATE', async () => {
|
||||
const tempId = -3;
|
||||
await offlineDb.places.put({ ...buildPlace({ trip_id: 1 }), id: tempId });
|
||||
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id, tempId }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'Bad' }, { status: 400 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
expect(await offlineDb.places.get(tempId)).toBeUndefined();
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('treats 429 as retryable: resets to pending and stops the flush', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'slow down' }, { status: 429 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
expect(m!.attempts).toBe(1);
|
||||
expect(await mutationQueue.failedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('treats 401 as retryable rather than dropping the change', async () => {
|
||||
const id = generateUUID();
|
||||
await mutationQueue.enqueue(makeMutation({ id }));
|
||||
|
||||
server.use(
|
||||
http.post('/api/trips/1/places', () => HttpResponse.json({ error: 'AUTH_REQUIRED' }, { status: 401 })),
|
||||
);
|
||||
|
||||
await mutationQueue.flush();
|
||||
|
||||
const m = await offlineDb.mutationQueue.get(id);
|
||||
expect(m!.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* requestPersistentStorage (H8 / M6) — best-effort persistent storage request
|
||||
* so prefetched tiles / file blobs / IndexedDB aren't evicted under pressure.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { requestPersistentStorage } from '../../../src/sync/persistentStorage';
|
||||
|
||||
const original = (navigator as Navigator & { storage?: StorageManager }).storage;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(navigator, 'storage', { value: original, configurable: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubStorage(storage: unknown) {
|
||||
Object.defineProperty(navigator, 'storage', { value: storage, configurable: true });
|
||||
}
|
||||
|
||||
describe('requestPersistentStorage', () => {
|
||||
it('requests persistence when not already granted', async () => {
|
||||
const persist = vi.fn().mockResolvedValue(true);
|
||||
const persisted = vi.fn().mockResolvedValue(false);
|
||||
stubStorage({ persist, persisted });
|
||||
|
||||
expect(await requestPersistentStorage()).toBe(true);
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('skips the prompt when already persisted', async () => {
|
||||
const persist = vi.fn().mockResolvedValue(true);
|
||||
const persisted = vi.fn().mockResolvedValue(true);
|
||||
stubStorage({ persist, persisted });
|
||||
|
||||
expect(await requestPersistentStorage()).toBe(true);
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns false (no throw) when the API is unavailable', async () => {
|
||||
stubStorage(undefined);
|
||||
expect(await requestPersistentStorage()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false (no throw) when persist rejects', async () => {
|
||||
stubStorage({ persist: vi.fn().mockRejectedValue(new Error('denied')) });
|
||||
expect(await requestPersistentStorage()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* syncTriggers — reconnect/online wiring (H1).
|
||||
*
|
||||
* Verifies the previously-dead refetch path is wired: on WS reconnect and on the
|
||||
* `online` event the active trip's store is re-hydrated (after the queue flush).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const flush = vi.fn(() => Promise.resolve());
|
||||
const syncAll = vi.fn(() => Promise.resolve());
|
||||
const hydrate = vi.fn(() => Promise.resolve());
|
||||
|
||||
let refetchCb: ((tripId: string) => void) | null = null;
|
||||
let preReconnect: (() => Promise<void>) | null = null;
|
||||
|
||||
vi.mock('../../../src/sync/mutationQueue', () => ({
|
||||
mutationQueue: { flush: () => flush() },
|
||||
}));
|
||||
vi.mock('../../../src/sync/tripSyncManager', () => ({
|
||||
tripSyncManager: { syncAll: () => syncAll() },
|
||||
}));
|
||||
vi.mock('../../../src/api/websocket', () => ({
|
||||
setPreReconnectHook: (fn: (() => Promise<void>) | null) => { preReconnect = fn; },
|
||||
setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; },
|
||||
getActiveTrips: () => ['7'],
|
||||
}));
|
||||
vi.mock('../../../src/store/tripStore', () => ({
|
||||
useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) },
|
||||
}));
|
||||
|
||||
import { registerSyncTriggers, unregisterSyncTriggers } from '../../../src/sync/syncTriggers';
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
flush.mockClear(); syncAll.mockClear(); hydrate.mockClear();
|
||||
refetchCb = null; preReconnect = null;
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unregisterSyncTriggers();
|
||||
});
|
||||
|
||||
describe('syncTriggers', () => {
|
||||
it('registers a refetch callback that hydrates the active trip', () => {
|
||||
registerSyncTriggers();
|
||||
expect(refetchCb).toBeTypeOf('function');
|
||||
refetchCb!('7');
|
||||
expect(hydrate).toHaveBeenCalledWith('7');
|
||||
});
|
||||
|
||||
it('also registers the pre-reconnect flush hook', () => {
|
||||
registerSyncTriggers();
|
||||
expect(preReconnect).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('clears both reconnect hooks on unregister', () => {
|
||||
registerSyncTriggers();
|
||||
unregisterSyncTriggers();
|
||||
expect(refetchCb).toBeNull();
|
||||
expect(preReconnect).toBeNull();
|
||||
});
|
||||
|
||||
it('online event flushes, then re-seeds Dexie and re-hydrates active trips', async () => {
|
||||
registerSyncTriggers();
|
||||
window.dispatchEvent(new Event('online'));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(flush).toHaveBeenCalled();
|
||||
expect(syncAll).toHaveBeenCalled();
|
||||
expect(hydrate).toHaveBeenCalledWith('7');
|
||||
});
|
||||
});
|
||||
@@ -207,17 +207,42 @@ describe('prefetchTilesForTrip', () => {
|
||||
expect(meta!.tilesBbox).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('skips prefetch when estimated tiles exceed MAX_TILES', async () => {
|
||||
it('zoom-clamps instead of skipping when the bbox exceeds MAX_TILES', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
// Places far apart → huge bbox → estimate > MAX_TILES
|
||||
// ~4° road-trip span: low zooms fit the budget, high zooms (z14+) blow past
|
||||
// it. The old guard skipped the whole trip; now we keep what fits.
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: -60, lng: -170 }),
|
||||
buildPlace({ trip_id: 1, lat: 60, lng: 170 }),
|
||||
buildPlace({ trip_id: 1, lat: 45.0, lng: 0.0 }),
|
||||
buildPlace({ trip_id: 1, lat: 49.0, lng: 4.0 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
// No fetches should have been made
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled();
|
||||
// Previously this skipped entirely; now it prefetches a clamped subset.
|
||||
const calls = vi.mocked(fetch).mock.calls.length;
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
|
||||
it('prefetches a region-sized (0.5°) trip that the old all-or-nothing guard would have skipped', async () => {
|
||||
await upsertSyncMeta({ tripId: 1, lastSyncedAt: Date.now(), status: 'idle', tilesBbox: null, filesCachedCount: 0 });
|
||||
|
||||
const places = [
|
||||
buildPlace({ trip_id: 1, lat: 48.6, lng: 2.1 }),
|
||||
buildPlace({ trip_id: 1, lat: 49.1, lng: 2.6 }),
|
||||
];
|
||||
await prefetchTilesForTrip(1, places, 'https://{s}.example.com/{z}/{x}/{y}.png');
|
||||
|
||||
const calls = vi.mocked(fetch).mock.calls.length;
|
||||
expect(calls).toBeGreaterThan(0);
|
||||
expect(calls).toBeLessThanOrEqual(MAX_TILES);
|
||||
});
|
||||
});
|
||||
|
||||
// ── cap coherence ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MAX_TILES budget', () => {
|
||||
it('matches the Workbox map-tiles maxEntries in vite.config.js (drift guard)', () => {
|
||||
expect(MAX_TILES).toBe(12288);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'fake-indexeddb/auto';
|
||||
import { server } from '../../helpers/msw/server';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { tripSyncManager } from '../../../src/sync/tripSyncManager';
|
||||
import { setAuthed } from '../../../src/sync/authGate';
|
||||
import { offlineDb, clearAll, upsertTrip } from '../../../src/db/offlineDb';
|
||||
import {
|
||||
buildTrip,
|
||||
@@ -45,6 +46,7 @@ function makeBundle(tripId: number) {
|
||||
beforeEach(async () => {
|
||||
await clearAll();
|
||||
tripSyncManager._resetSyncing();
|
||||
setAuthed(true);
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true });
|
||||
// Stub fetch for blob caching (used by cacheFilesForTrip)
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
@@ -56,6 +58,19 @@ beforeEach(async () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
setAuthed(false);
|
||||
});
|
||||
|
||||
describe('tripSyncManager.syncAll — auth gate (B4)', () => {
|
||||
it('no-ops when logged out (gate closed)', async () => {
|
||||
setAuthed(false);
|
||||
let called = false;
|
||||
server.use(
|
||||
http.get('/api/trips', () => { called = true; return HttpResponse.json({ trips: [] }); }),
|
||||
);
|
||||
await tripSyncManager.syncAll();
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── offline guard ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { useTripStore } from '../../src/store/tripStore';
|
||||
import { resetAllStores } from '../helpers/store';
|
||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote } from '../helpers/factories';
|
||||
import { buildTrip, buildDay, buildPlace, buildPackingItem, buildTodoItem, buildTag, buildCategory, buildAssignment, buildDayNote, buildBudgetItem, buildReservation, buildTripFile } from '../helpers/factories';
|
||||
import { server } from '../helpers/msw/server';
|
||||
|
||||
vi.mock('../../src/api/websocket', () => ({
|
||||
@@ -21,6 +21,28 @@ beforeEach(() => {
|
||||
resetAllStores();
|
||||
});
|
||||
|
||||
/** Full set of MSW handlers for one trip's loadTrip fan-out. */
|
||||
function tripHandlers(
|
||||
id: number,
|
||||
data: {
|
||||
budget?: unknown[]; reservations?: unknown[]; files?: unknown[];
|
||||
tags?: unknown[]; categories?: unknown[];
|
||||
},
|
||||
) {
|
||||
return [
|
||||
http.get(`/api/trips/${id}`, () => HttpResponse.json({ trip: buildTrip({ id }) })),
|
||||
http.get(`/api/trips/${id}/days`, () => HttpResponse.json({ days: [] })),
|
||||
http.get(`/api/trips/${id}/places`, () => HttpResponse.json({ places: [] })),
|
||||
http.get(`/api/trips/${id}/packing`, () => HttpResponse.json({ items: [] })),
|
||||
http.get(`/api/trips/${id}/todo`, () => HttpResponse.json({ items: [] })),
|
||||
http.get(`/api/trips/${id}/budget`, () => HttpResponse.json({ items: data.budget ?? [] })),
|
||||
http.get(`/api/trips/${id}/reservations`, () => HttpResponse.json({ reservations: data.reservations ?? [] })),
|
||||
http.get(`/api/trips/${id}/files`, () => HttpResponse.json({ files: data.files ?? [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: data.tags ?? [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: data.categories ?? [] })),
|
||||
];
|
||||
}
|
||||
|
||||
describe('tripStore', () => {
|
||||
describe('loadTrip', () => {
|
||||
it('FE-TRIP-001: fires parallel API calls for trips, days, places, packing, todo, tags, categories', async () => {
|
||||
@@ -178,6 +200,97 @@ describe('tripStore', () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
expect(state.error).not.toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TRIP-H5: loadTrip uniformly hydrates budget, reservations and files', async () => {
|
||||
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||
const reservation = buildReservation({ trip_id: 1 });
|
||||
const file = buildTripFile({ trip_id: 1 });
|
||||
server.use(...tripHandlers(1, { budget: [budgetItem], reservations: [reservation], files: [file] }));
|
||||
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.budgetItems).toEqual([budgetItem]);
|
||||
expect(state.reservations).toEqual([reservation]);
|
||||
expect(state.files).toEqual([file]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-H4: switching trips does not leak budget/reservations/files from the previous trip', async () => {
|
||||
// Trip 1 has budget/reservations/files; trip 2 has none.
|
||||
server.use(...tripHandlers(1, {
|
||||
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||
reservations: [buildReservation({ trip_id: 1 })],
|
||||
files: [buildTripFile({ trip_id: 1 })],
|
||||
}));
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
|
||||
server.use(...tripHandlers(2, {}));
|
||||
await useTripStore.getState().loadTrip(2);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.trip!.id).toBe(2);
|
||||
expect(state.budgetItems).toEqual([]);
|
||||
expect(state.reservations).toEqual([]);
|
||||
expect(state.files).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TRIP-H4b: resetTrip clears every trip-scoped slice but keeps tags/categories', async () => {
|
||||
server.use(...tripHandlers(1, {
|
||||
budget: [buildBudgetItem({ trip_id: 1 })],
|
||||
reservations: [buildReservation({ trip_id: 1 })],
|
||||
files: [buildTripFile({ trip_id: 1 })],
|
||||
tags: [buildTag()],
|
||||
}));
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().budgetItems).toHaveLength(1);
|
||||
|
||||
useTripStore.getState().resetTrip();
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.trip).toBeNull();
|
||||
expect(state.places).toEqual([]);
|
||||
expect(state.budgetItems).toEqual([]);
|
||||
expect(state.reservations).toEqual([]);
|
||||
expect(state.files).toEqual([]);
|
||||
expect(state.selectedDayId).toBeNull();
|
||||
// Global lookups survive a trip reset.
|
||||
expect(state.tags).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hydrateActiveTrip', () => {
|
||||
const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [
|
||||
http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })),
|
||||
http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })),
|
||||
http.get('/api/trips/1/places', () => HttpResponse.json({ places })),
|
||||
http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })),
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })),
|
||||
http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })),
|
||||
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
|
||||
http.get('/api/tags', () => HttpResponse.json({ tags: [] })),
|
||||
http.get('/api/categories', () => HttpResponse.json({ categories: [] })),
|
||||
];
|
||||
|
||||
it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => {
|
||||
server.use(...loadHandlers());
|
||||
await useTripStore.getState().loadTrip(1);
|
||||
expect(useTripStore.getState().trip!.id).toBe(1);
|
||||
|
||||
// New collaborative state arrives (as if edited by someone while we were offline).
|
||||
const place = buildPlace({ trip_id: 1 });
|
||||
const budgetItem = buildBudgetItem({ trip_id: 1 });
|
||||
server.use(...loadHandlers([place], [budgetItem]));
|
||||
|
||||
await useTripStore.getState().hydrateActiveTrip(1);
|
||||
const state = useTripStore.getState();
|
||||
|
||||
expect(state.places).toEqual([place]);
|
||||
expect(state.budgetItems).toEqual([budgetItem]);
|
||||
expect(state.trip!.id).toBe(1); // trip not reset
|
||||
expect(state.isLoading).toBe(false); // no splash toggled
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshDays', () => {
|
||||
|
||||
+28
-9
@@ -15,21 +15,25 @@ export default defineConfig({
|
||||
runtimeCaching: [
|
||||
{
|
||||
// Carto map tiles (default provider)
|
||||
// maxEntries MUST stay >= MAX_TILES in src/sync/tilePrefetcher.ts
|
||||
// (both are 12288) so prefetched tiles aren't evicted on arrival.
|
||||
urlPattern: /^https:\/\/[a-d]\.basemaps\.cartocdn\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// OpenStreetMap tiles (fallback / alternative)
|
||||
// Shares the 'map-tiles' cache; keep maxEntries equal to the Carto
|
||||
// rule above and MAX_TILES in src/sync/tilePrefetcher.ts (12288).
|
||||
urlPattern: /^https:\/\/[a-c]\.tile\.openstreetmap\.org\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 1000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
expiration: { maxEntries: 12288, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
@@ -44,17 +48,32 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
{
|
||||
// API calls — prefer network, fall back to cache
|
||||
// Exclude sensitive endpoints (auth, admin, backup, settings)
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkFirst',
|
||||
// Mapbox GL style, glyphs, sprites and vector tiles. Best-effort
|
||||
// offline only: opportunistically caches what the user has already
|
||||
// viewed online. Full pre-download offline maps require the Leaflet
|
||||
// renderer (raster prefetch in tilePrefetcher.ts) — the GL vector
|
||||
// pipeline is not prefetched. StaleWhileRevalidate keeps the basemap
|
||||
// fresh online while still serving from cache when offline. Mapbox
|
||||
// sends CORS, so responses are non-opaque (real 200s, no quota pad).
|
||||
urlPattern: /^https:\/\/(api\.mapbox\.com|[a-d]\.tiles\.mapbox\.com)\/.*/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'api-data',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 },
|
||||
networkTimeoutSeconds: 5,
|
||||
cacheName: 'mapbox-tiles',
|
||||
expiration: { maxEntries: 3000, maxAgeSeconds: 30 * 24 * 60 * 60 },
|
||||
cacheableResponse: { statuses: [200] },
|
||||
},
|
||||
},
|
||||
{
|
||||
// API calls — network only. We deliberately do NOT cache API
|
||||
// responses in the Service Worker: Workbox keys entries by URL and
|
||||
// cannot vary on the httpOnly session cookie, so a shared device
|
||||
// could serve one user's cached data to the next (cross-user leak).
|
||||
// Offline reads are served from the per-user IndexedDB cache via the
|
||||
// repo layer instead. The urlPattern is kept so these requests still
|
||||
// bypass the SPA navigation fallback.
|
||||
urlPattern: /\/api\/(?!auth|admin|backup|settings|health).*/i,
|
||||
handler: 'NetworkOnly',
|
||||
},
|
||||
{
|
||||
// Uploaded files (photos, covers — public assets only)
|
||||
urlPattern: /\/uploads\/(?:covers|avatars)\/.*/i,
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info} # info = concise user actions; debug = verbose admin-level details
|
||||
# - DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# - SESSION_DURATION=30d # How long users stay logged in (trek_session JWT + cookie maxAge). Accepts: 1h | 12h | 7d | 30d | 90d. Default: 24h
|
||||
# - SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login: longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
|
||||
- ALLOWED_ORIGINS=${ALLOWED_ORIGINS:-} # Comma-separated origins for CORS and email notification links
|
||||
# - FORCE_HTTPS=true # Optional. Enables HTTPS redirect, HSTS, CSP upgrade-insecure-requests, and secure cookies behind a TLS proxy
|
||||
# - HSTS_INCLUDE_SUBDOMAINS=false # When true: adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave false if sibling subdomains still run over plain HTTP.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 321 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 2.1 MiB |
Generated
+5578
-2345
File diff suppressed because it is too large
Load Diff
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@trek/root",
|
||||
"private": true,
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.0",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"server",
|
||||
@@ -25,7 +25,7 @@
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
"concurrently": "^10.0.3"
|
||||
},
|
||||
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
|
||||
"overrides": {
|
||||
@@ -33,9 +33,9 @@
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.4",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.4",
|
||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||
"@img/sharp-linuxmusl-arm64": "0.33.5"
|
||||
"@rollup/rollup-linux-x64-musl": "4.62.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.62.0",
|
||||
"@img/sharp-linuxmusl-x64": "0.35.1",
|
||||
"@img/sharp-linuxmusl-arm64": "0.35.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ NODE_ENV=development # development = development mode; production = production m
|
||||
TZ=UTC # Timezone for logs, reminders and scheduled tasks (e.g. Europe/Berlin)
|
||||
# DEFAULT_LANGUAGE=en # Default language on the login page for users with no saved preference (default: en)
|
||||
# SESSION_DURATION=30d # How long users stay logged in — sets the trek_session JWT exp + cookie maxAge. Accepts 1h, 12h, 7d, 30d, 90d. Default: 24h
|
||||
# SESSION_DURATION_REMEMBER=30d # Session length when "Remember me" is ticked at login — longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Default: 30d
|
||||
# Supported values: de, en, es, fr, hu, nl, br, cs, pl, ru, zh, zh-TW, it, ar
|
||||
# Note: browser/OS language is detected automatically first; this is the fallback when no match is found.
|
||||
LOG_LEVEL=info # info = concise user actions; debug = verbose admin-level details
|
||||
|
||||
+14
-13
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@trek/server",
|
||||
"version": "3.0.22",
|
||||
"version": "3.1.0",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --require tsconfig-paths/register dist/index.js",
|
||||
@@ -21,13 +21,12 @@
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trek/shared": "*",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
@@ -47,6 +46,7 @@
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"semver": "^7.7.4",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"undici": "^7.0.0",
|
||||
@@ -67,16 +67,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
@@ -94,11 +87,19 @@
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/coverage-istanbul": "^4.1.9",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"supertest": "^7.2.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"tz-lookup": "^6.1.25",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^3.2.4"
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,3 +136,21 @@ export const SESSION_DURATION = parsedSessionMs == null ? DEFAULT_SESSION_DURATI
|
||||
export const SESSION_DURATION_MS = parsedSessionMs ?? parseDurationMs(DEFAULT_SESSION_DURATION)!;
|
||||
/** Session length in seconds — passed to `jwt.sign({ expiresIn })` (number = seconds). */
|
||||
export const SESSION_DURATION_SECONDS = Math.floor(SESSION_DURATION_MS / 1000);
|
||||
|
||||
// SESSION_DURATION_REMEMBER is the session length used when the user ticks
|
||||
// "Remember me" on the login form: a longer-lived JWT `exp` claim plus a
|
||||
// persistent `trek_session` cookie `maxAge`. An unticked login keeps
|
||||
// SESSION_DURATION and a browser-session cookie (no `maxAge`). Same ms-style
|
||||
// format and fallback behavior as SESSION_DURATION.
|
||||
const DEFAULT_SESSION_DURATION_REMEMBER = '30d';
|
||||
const rawRememberDuration = process.env.SESSION_DURATION_REMEMBER?.trim() || DEFAULT_SESSION_DURATION_REMEMBER;
|
||||
const parsedRememberMs = parseDurationMs(rawRememberDuration);
|
||||
if (parsedRememberMs == null) {
|
||||
console.warn(`SESSION_DURATION_REMEMBER="${rawRememberDuration}" is not a valid duration (use e.g. 7d, 30d, 90d). Falling back to "${DEFAULT_SESSION_DURATION_REMEMBER}".`);
|
||||
}
|
||||
/** Human-readable "remember me" session length actually in effect (for logs/diagnostics). */
|
||||
export const SESSION_DURATION_REMEMBER = parsedRememberMs == null ? DEFAULT_SESSION_DURATION_REMEMBER : rawRememberDuration;
|
||||
/** "Remember me" session length in milliseconds — used for the persistent cookie `maxAge`. */
|
||||
export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!;
|
||||
/** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */
|
||||
export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000);
|
||||
|
||||
+838
-282
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,7 @@ const onListen = () => {
|
||||
scheduler.startDemoReset();
|
||||
scheduler.startIdempotencyCleanup();
|
||||
scheduler.startTrekPhotoCacheCleanup();
|
||||
scheduler.startPlacePhotoCacheCleanup();
|
||||
scheduler.startAirTrailSync();
|
||||
const { startTokenCleanup } = require('./services/ephemeralTokens');
|
||||
startTokenCleanup();
|
||||
|
||||
@@ -96,7 +96,7 @@ export function applyGlobalMiddleware(
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.exchangerate-api.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
|
||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com"
|
||||
],
|
||||
|
||||
@@ -87,7 +87,7 @@ export class AuthPublicController {
|
||||
if (result.mfa_required) {
|
||||
return { mfa_required: true, mfa_token: result.mfa_token };
|
||||
}
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export class AuthPublicController {
|
||||
throw new HttpException({ error: result.error }, result.status!);
|
||||
}
|
||||
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
|
||||
this.auth.setAuthCookie(res, result.token!, req);
|
||||
this.auth.setAuthCookie(res, result.token!, req, result.remember);
|
||||
return { token: result.token, user: result.user };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { User } from '../../types';
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
// Cookie
|
||||
setAuthCookie(res: Response, token: string, req: Request) { setAuthCookie(res, token, req); }
|
||||
setAuthCookie(res: Response, token: string, req: Request, remember?: boolean) { setAuthCookie(res, token, req, remember); }
|
||||
clearAuthCookie(res: Response, req: Request) { clearAuthCookie(res, req); }
|
||||
|
||||
// Reset-email delivery (canonical app URL, never request headers)
|
||||
|
||||
+57
-7
@@ -291,20 +291,45 @@ function startVersionCheck(): void {
|
||||
}, { timezone: tz });
|
||||
}
|
||||
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys older than 24 hours
|
||||
// Idempotency key cleanup: nightly at 3 AM — delete keys past their TTL.
|
||||
// The TTL must exceed any realistic offline window: the TREK client replays
|
||||
// queued mutations with their X-Idempotency-Key when it reconnects, so a key
|
||||
// GC'd before the device comes back online would let the replay create a
|
||||
// duplicate. 24h was far too short for a multi-day offline trip; default 30d,
|
||||
// overridable via IDEMPOTENCY_TTL_SECONDS.
|
||||
const DEFAULT_IDEMPOTENCY_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
||||
let idempotencyCleanupTask: ScheduledTask | null = null;
|
||||
|
||||
function idempotencyTtlSeconds(): number {
|
||||
const n = Number(process.env.IDEMPOTENCY_TTL_SECONDS);
|
||||
return Number.isFinite(n) && n > 0 ? n : DEFAULT_IDEMPOTENCY_TTL_SECONDS;
|
||||
}
|
||||
|
||||
interface PurgeDb {
|
||||
prepare(sql: string): { run(...args: unknown[]): { changes: number } };
|
||||
}
|
||||
|
||||
/** Delete idempotency keys older than the configured TTL. Returns rows removed.
|
||||
* The db is injectable for testing; the cron job uses the default. */
|
||||
function purgeExpiredIdempotencyKeys(
|
||||
now: number = Date.now(),
|
||||
ttlSeconds: number = idempotencyTtlSeconds(),
|
||||
database: PurgeDb = require('./db/database').db,
|
||||
): number {
|
||||
const cutoff = Math.floor(now / 1000) - ttlSeconds;
|
||||
const result = database.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
function startIdempotencyCleanup(): void {
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
idempotencyCleanupTask = cron.schedule('0 3 * * *', () => {
|
||||
try {
|
||||
const { db } = require('./db/database');
|
||||
const cutoff = Math.floor(Date.now() / 1000) - 86400;
|
||||
const result = db.prepare('DELETE FROM idempotency_keys WHERE created_at < ?').run(cutoff);
|
||||
if (result.changes > 0) {
|
||||
logInfo(`Idempotency cleanup: removed ${result.changes} expired key(s)`);
|
||||
const removed = purgeExpiredIdempotencyKeys();
|
||||
if (removed > 0) {
|
||||
logInfo(`Idempotency cleanup: removed ${removed} expired key(s)`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
logError(`Idempotency cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
@@ -334,6 +359,30 @@ function startTrekPhotoCacheCleanup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Place-photo (Google/Wikimedia) cache cleanup: nightly — reclaim cached files and
|
||||
// meta rows no place references anymore (deleted places/trips, overwritten image_url).
|
||||
let placePhotoCacheTask: ScheduledTask | null = null;
|
||||
|
||||
function startPlacePhotoCacheCleanup(): void {
|
||||
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
|
||||
|
||||
const sweep = () => {
|
||||
try {
|
||||
const { sweepOrphans } = require('./services/placePhotoCache');
|
||||
const removed = sweepOrphans();
|
||||
if (removed > 0) logInfo(`Place-photo cache cleanup: removed ${removed} orphaned file(s)/row(s)`);
|
||||
} catch (err: unknown) {
|
||||
logError(`Place-photo cache cleanup: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Run once on startup to reclaim orphans left over from before this sweeper existed.
|
||||
sweep();
|
||||
|
||||
const tz = process.env.TZ || 'UTC';
|
||||
placePhotoCacheTask = cron.schedule('30 3 * * *', sweep, { timezone: tz });
|
||||
}
|
||||
|
||||
// AirTrail sync: poll connected instances on an interval and reconcile linked
|
||||
// flights both ways (#214). The per-tick enable gate (addon + setting) lives in
|
||||
// runAirtrailSync, so toggling the addon takes effect without a restart.
|
||||
@@ -366,7 +415,8 @@ function stop(): void {
|
||||
if (versionCheckTask) { versionCheckTask.stop(); versionCheckTask = null; }
|
||||
if (idempotencyCleanupTask) { idempotencyCleanupTask.stop(); idempotencyCleanupTask = null; }
|
||||
if (trekPhotoCacheTask) { trekPhotoCacheTask.stop(); trekPhotoCacheTask = null; }
|
||||
if (placePhotoCacheTask) { placePhotoCacheTask.stop(); placePhotoCacheTask = null; }
|
||||
if (airtrailSyncTask) { airtrailSyncTask.stop(); airtrailSyncTask = null; }
|
||||
}
|
||||
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, startTrekPhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
export { start, stop, startDemoReset, startTripReminders, startTodoReminders, startVersionCheck, startIdempotencyCleanup, purgeExpiredIdempotencyKeys, startTrekPhotoCacheCleanup, startPlacePhotoCacheCleanup, startAirTrailSync, loadSettings, saveSettings, VALID_INTERVALS };
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { db } from '../../db/database';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { broadcast } from '../../websocket';
|
||||
import { isAddonEnabled } from '../adminService';
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { logError, logInfo } from '../auditLog';
|
||||
import { getReservation, getReservationWithJoins, updateReservation } from '../reservationService';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
import {
|
||||
AirtrailAuthError,
|
||||
AirtrailCreds,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
saveFlight,
|
||||
} from './airtrailClient';
|
||||
import { canonicalHash, mapFlightToReservation } from './airtrailMapper';
|
||||
import { getAirtrailCredentials } from './airtrailService';
|
||||
|
||||
/** Global on/off: the addon must be enabled and sync not explicitly turned off. */
|
||||
export function syncGloballyEnabled(): boolean {
|
||||
@@ -59,7 +59,7 @@ async function syncOwner(uid: number): Promise<number> {
|
||||
if (err instanceof AirtrailAuthError) logError(`AirTrail sync: invalid API key for user ${uid}`);
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map(flights.map(f => [String(f.id), f]));
|
||||
const byId = new Map(flights.map((f) => [String(f.id), f]));
|
||||
|
||||
const linked = db
|
||||
.prepare(
|
||||
@@ -145,15 +145,15 @@ function splitLocal(dt: string | null | undefined): { date: string | null; time:
|
||||
}
|
||||
|
||||
function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): AirtrailSavePayload | null {
|
||||
let meta: Record<string, any> = {};
|
||||
let meta: Record<string, any>;
|
||||
try {
|
||||
meta = reservation.metadata ? JSON.parse(reservation.metadata) : {};
|
||||
} catch {
|
||||
meta = {};
|
||||
}
|
||||
const endpoints: any[] = reservation.endpoints || [];
|
||||
const fromEp = endpoints.find(e => e.role === 'from');
|
||||
const toEp = endpoints.find(e => e.role === 'to');
|
||||
const fromEp = endpoints.find((e) => e.role === 'from');
|
||||
const toEp = endpoints.find((e) => e.role === 'to');
|
||||
const fromCode = fromEp?.code || existing.from?.iata || existing.from?.icao || null;
|
||||
const toCode = toEp?.code || existing.to?.iata || existing.to?.icao || null;
|
||||
if (!fromCode || !toCode) return null;
|
||||
@@ -164,7 +164,7 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
||||
|
||||
// Preserve the existing seat manifest (an update replaces all seats); fall back
|
||||
// to the key-owner placeholder so AirTrail attributes it to the connecting user.
|
||||
const seats = (existing.seats ?? []).map(s => ({
|
||||
const seats = (existing.seats ?? []).map((s) => ({
|
||||
userId: s.userId,
|
||||
guestName: s.guestName,
|
||||
seat: s.seat,
|
||||
@@ -179,7 +179,7 @@ function buildSavePayload(reservation: any, existing: AirtrailFlightRaw): Airtra
|
||||
// a userId), leaving any co-passenger seats untouched.
|
||||
const seatNumber = typeof meta.seat === 'string' && meta.seat.trim() ? meta.seat.trim() : null;
|
||||
if (seatNumber) {
|
||||
const ownSeat = seats.find(s => s.userId) ?? seats[0];
|
||||
const ownSeat = seats.find((s) => s.userId) ?? seats[0];
|
||||
if (ownSeat) ownSeat.seatNumber = seatNumber;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { authenticator } from 'otplib';
|
||||
import QRCode from 'qrcode';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
import { db } from '../db/database';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS } from '../config';
|
||||
import { JWT_SECRET, SESSION_DURATION_SECONDS, SESSION_DURATION_REMEMBER_SECONDS } from '../config';
|
||||
import { validatePassword } from './passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from './mfaCrypto';
|
||||
import { getAllPermissions } from './permissions';
|
||||
@@ -181,14 +181,17 @@ export function isOidcOnlyMode(): boolean {
|
||||
return !resolveAuthToggles().password_login;
|
||||
}
|
||||
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }) {
|
||||
export function generateToken(user: { id: number | bigint; password_version?: number }, rememberMe = false) {
|
||||
const pv = typeof user.password_version === 'number'
|
||||
? user.password_version
|
||||
: ((db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0);
|
||||
// "Remember me" extends the JWT lifetime to match the persistent cookie maxAge;
|
||||
// the cookie service decides session-vs-persistent off the same flag.
|
||||
const expiresIn = rememberMe ? SESSION_DURATION_REMEMBER_SECONDS : SESSION_DURATION_SECONDS;
|
||||
return jwt.sign(
|
||||
{ id: user.id, pv },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' }
|
||||
{ expiresIn, algorithm: 'HS256' }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -443,6 +446,7 @@ export function registerUser(body: {
|
||||
export function loginUser(body: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
@@ -450,6 +454,7 @@ export function loginUser(body: {
|
||||
user?: Record<string, unknown>;
|
||||
mfa_required?: boolean;
|
||||
mfa_token?: string;
|
||||
remember?: boolean;
|
||||
auditUserId?: number | null;
|
||||
auditAction?: string;
|
||||
auditDetails?: Record<string, unknown>;
|
||||
@@ -458,7 +463,8 @@ export function loginUser(body: {
|
||||
return { error: 'Password authentication is disabled. Please sign in with SSO.', status: 403 };
|
||||
}
|
||||
|
||||
const { email, password } = body;
|
||||
const { email, password, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!email || !password) {
|
||||
return { error: 'Email and password are required', status: 400 };
|
||||
}
|
||||
@@ -500,12 +506,13 @@ export function loginUser(body: {
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const token = generateToken(user);
|
||||
const token = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
token,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
auditAction: 'user.login',
|
||||
auditDetails: { email },
|
||||
@@ -1066,14 +1073,17 @@ export function disableMfa(
|
||||
export function verifyMfaLogin(body: {
|
||||
mfa_token?: string;
|
||||
code?: string;
|
||||
remember_me?: boolean;
|
||||
}): {
|
||||
error?: string;
|
||||
status?: number;
|
||||
token?: string;
|
||||
user?: Record<string, unknown>;
|
||||
remember?: boolean;
|
||||
auditUserId?: number;
|
||||
} {
|
||||
const { mfa_token, code } = body;
|
||||
const { mfa_token, code, remember_me } = body;
|
||||
const remember = remember_me === true;
|
||||
if (!mfa_token || !code) {
|
||||
return { error: 'Verification token and code are required', status: 400 };
|
||||
}
|
||||
@@ -1104,11 +1114,12 @@ export function verifyMfaLogin(body: {
|
||||
);
|
||||
}
|
||||
db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP, login_count = login_count + 1 WHERE id = ?').run(user.id);
|
||||
const sessionToken = generateToken(user);
|
||||
const sessionToken = generateToken(user, remember);
|
||||
const userSafe = stripUserForClient(user) as Record<string, unknown>;
|
||||
return {
|
||||
token: sessionToken,
|
||||
user: { ...userSafe, avatar_url: avatarUrl(user) },
|
||||
remember,
|
||||
auditUserId: Number(user.id),
|
||||
};
|
||||
} catch {
|
||||
|
||||
@@ -155,8 +155,26 @@ export async function createBackup(): Promise<BackupInfo> {
|
||||
archive.file(dbPath, { name: 'travel.db' });
|
||||
}
|
||||
|
||||
// Bundle the at-rest encryption key so the backup is self-contained: the
|
||||
// DB stores secrets (API keys, MFA, SMTP/OIDC) encrypted with this key, so
|
||||
// a restore onto a different install would otherwise be unable to decrypt
|
||||
// them. NOTE: this makes the backup file as sensitive as the key itself —
|
||||
// store/transfer it securely. Skipped when ENCRYPTION_KEY is provided via
|
||||
// env, since in that case the file is not the source of truth.
|
||||
const encKeyPath = path.join(dataDir, '.encryption_key');
|
||||
if (!process.env.ENCRYPTION_KEY && fs.existsSync(encKeyPath)) {
|
||||
archive.file(encKeyPath, { name: '.encryption_key' });
|
||||
}
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
archive.directory(uploadsDir, 'uploads');
|
||||
// Exclude the place-photo and trek-memory caches: both are re-derivable
|
||||
// (re-fetched on demand, keyed on stable ids) and would otherwise dominate
|
||||
// backup size. Restores self-heal — the cache dirs are recreated at startup.
|
||||
archive.glob(
|
||||
'**/*',
|
||||
{ cwd: uploadsDir, ignore: ['photos/google/**', 'photos/trek/**'], nodir: true, dot: true },
|
||||
{ prefix: 'uploads' },
|
||||
);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
@@ -245,6 +263,16 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
fs.copyFileSync(extractedDb, dbDest);
|
||||
|
||||
// Restore the bundled at-rest encryption key (if the archive carries one)
|
||||
// so the restored DB's encrypted secrets can be decrypted. Only the file
|
||||
// is swapped here; the in-memory key was read at startup, so a restart is
|
||||
// required for it to take effect (and an explicit ENCRYPTION_KEY env var
|
||||
// still overrides the file).
|
||||
const extractedEncKey = path.join(extractDir, '.encryption_key');
|
||||
if (fs.existsSync(extractedEncKey)) {
|
||||
fs.copyFileSync(extractedEncKey, path.join(dataDir, '.encryption_key'));
|
||||
}
|
||||
|
||||
const extractedUploads = path.join(extractDir, 'uploads');
|
||||
if (fs.existsSync(extractedUploads)) {
|
||||
for (const sub of fs.readdirSync(uploadsDir)) {
|
||||
@@ -255,7 +283,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true });
|
||||
// Copy into the real directory behind uploadsDir. In Docker, uploadsDir
|
||||
// (/app/server/uploads) is a symlink to the mounted /app/uploads volume;
|
||||
// cpSync(dereference:false) would otherwise try to overwrite the symlink
|
||||
// node with a directory and throw ERR_FS_CP_DIR_TO_NON_DIR. realpathSync
|
||||
// is a no-op when uploadsDir is a plain directory (dev/non-Docker).
|
||||
fs.cpSync(extractedUploads, fs.realpathSync(uploadsDir), { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
// Reopening the DB must always run (even if the copy above threw) so the
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { SESSION_DURATION_MS } from '../config';
|
||||
import { SESSION_DURATION_MS, SESSION_DURATION_REMEMBER_MS } from '../config';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
/**
|
||||
* Controls the cookie lifetime for a login:
|
||||
* - `undefined` → persistent `maxAge: SESSION_DURATION_MS` (the historical
|
||||
* default, used by register/demo and anything that doesn't opt in).
|
||||
* - `true` → persistent `maxAge: SESSION_DURATION_REMEMBER_MS` ("Remember me").
|
||||
* - `false` → no `maxAge` — a browser-session cookie cleared on browser close.
|
||||
*/
|
||||
export type RememberOption = boolean | undefined;
|
||||
|
||||
/**
|
||||
* Decide whether the session cookie should carry the `Secure` flag.
|
||||
*
|
||||
@@ -18,27 +27,35 @@ const COOKIE_NAME = 'trek_session';
|
||||
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
export function cookieOptions(clear = false, req?: Request, remember?: RememberOption) {
|
||||
if (process.env.COOKIE_SECURE?.toLowerCase() === 'false') {
|
||||
return buildOptions(clear, false);
|
||||
return buildOptions(clear, false, remember);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV?.toLowerCase() === 'production' || process.env.FORCE_HTTPS?.toLowerCase() === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
return buildOptions(clear, envSecure || requestSecure, remember);
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean) {
|
||||
function resolveMaxAge(remember: RememberOption): { maxAge: number } | Record<string, never> {
|
||||
// false → session cookie (omit maxAge); true → the longer "remember me"
|
||||
// window; undefined → the historical default. Each maxAge matches the JWT exp.
|
||||
if (remember === false) return {};
|
||||
if (remember === true) return { maxAge: SESSION_DURATION_REMEMBER_MS };
|
||||
return { maxAge: SESSION_DURATION_MS };
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean, remember?: RememberOption) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
sameSite: 'lax' as const,
|
||||
path: '/',
|
||||
...(clear ? {} : { maxAge: SESSION_DURATION_MS }), // matches the JWT expiry (SESSION_DURATION)
|
||||
...(clear ? {} : resolveMaxAge(remember)),
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request, remember?: RememberOption): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req, remember));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Live exchange rates for the Costs/Budget money conversion.
|
||||
*
|
||||
* Fetches from exchangerate-api.com (no key, already CSP-allowlisted for the
|
||||
* Fetches from api.frankfurter.dev (no key, already CSP-allowlisted for the
|
||||
* dashboard widget) and caches per base currency in-memory for a few hours so a
|
||||
* settlement request never hammers the upstream. Rates are "units of X per 1
|
||||
* base", so an amount in currency C converts to base as `amount / rates[C]`.
|
||||
@@ -17,10 +17,17 @@ const inflight = new Map<string, Promise<Record<string, number> | null>>();
|
||||
|
||||
async function fetchRates(base: string): Promise<Record<string, number> | null> {
|
||||
try {
|
||||
const res = await fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(base)}`);
|
||||
const res = await fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(base)}`);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { rates?: Record<string, number> };
|
||||
return data.rates && typeof data.rates === 'object' ? data.rates : null;
|
||||
// Frankfurter returns an array of { date, base, quote, rate } and omits the
|
||||
// base's own self-rate, so seed the map with `base = 1` then index by quote.
|
||||
const data = (await res.json()) as Array<{ quote?: string; rate?: number }>;
|
||||
if (!Array.isArray(data)) return null;
|
||||
const rates: Record<string, number> = { [base.toUpperCase()]: 1 };
|
||||
for (const r of data) {
|
||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate;
|
||||
}
|
||||
return Object.keys(rates).length > 1 ? rates : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ interface OverpassElement {
|
||||
}
|
||||
|
||||
interface WikiCommonsPage {
|
||||
imageinfo?: { url?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
imageinfo?: { url?: string; thumburl?: string; extmetadata?: { Artist?: { value?: string } } }[];
|
||||
}
|
||||
|
||||
interface GooglePlaceResult {
|
||||
@@ -537,7 +537,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
const mime = (info as { mime?: string })?.mime || '';
|
||||
if (info?.url && (mime.startsWith('image/jpeg') || mime.startsWith('image/png'))) {
|
||||
const attribution = info.extmetadata?.Artist?.value?.replace(/<[^>]+>/g, '').trim() || null;
|
||||
return { photoUrl: info.url, attribution };
|
||||
// iiurlwidth=400 makes Commons also return a scaled thumburl. Prefer it —
|
||||
// info.url is the full-resolution original (multi-megapixel camera exports).
|
||||
return { photoUrl: info.thumburl ?? info.url, attribution };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import crypto from 'node:crypto';
|
||||
import { db } from '../db/database';
|
||||
|
||||
const GOOGLE_PHOTO_DIR = path.join(__dirname, '../../uploads/photos/google');
|
||||
import { Jimp, JimpMime } from 'jimp';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsPromises from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
// Overridable for tests (mirrors the TREK_DB_FILE seam) so the suite never touches
|
||||
// the real uploads tree.
|
||||
const GOOGLE_PHOTO_DIR = process.env.TREK_PLACE_PHOTO_DIR || path.join(__dirname, '../../uploads/photos/google');
|
||||
const ERROR_TTL = 5 * 60 * 1000;
|
||||
|
||||
// Marker photos are displayed tiny — cap stored images so an oversized source
|
||||
// (e.g. a Wikimedia Commons full-res original) can't bloat the cache. Matches
|
||||
// THUMB_MAX/THUMB_QUALITY in memories/thumbnailService.ts.
|
||||
const MAX_DIM = 800;
|
||||
const JPEG_QUALITY = 80;
|
||||
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
@@ -17,7 +27,9 @@ const knownOnDisk = new Set<string>();
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch { /* already exists */ }
|
||||
} catch {
|
||||
/* already exists */
|
||||
}
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
@@ -37,9 +49,9 @@ interface CachedPhoto {
|
||||
}
|
||||
|
||||
export function get(placeId: string): CachedPhoto | null {
|
||||
const row = db.prepare(
|
||||
'SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL'
|
||||
).get(placeId) as { attribution: string | null } | undefined;
|
||||
const row = db
|
||||
.prepare('SELECT attribution FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NULL')
|
||||
.get(placeId) as { attribution: string | null } | undefined;
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
@@ -59,9 +71,9 @@ export function get(placeId: string): CachedPhoto | null {
|
||||
}
|
||||
|
||||
export function getErrored(placeId: string): boolean {
|
||||
const row = db.prepare(
|
||||
'SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL'
|
||||
).get(placeId) as { error_at: number } | undefined;
|
||||
const row = db
|
||||
.prepare('SELECT error_at FROM google_place_photo_meta WHERE place_id = ? AND error_at IS NOT NULL')
|
||||
.get(placeId) as { error_at: number } | undefined;
|
||||
|
||||
if (!row) return false;
|
||||
return Date.now() - row.error_at < ERROR_TTL;
|
||||
@@ -70,35 +82,58 @@ export function getErrored(placeId: string): boolean {
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)',
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
// Downscale oversized images to MAX_DIM before caching, re-encoding to JPEG.
|
||||
// Defense-in-depth: keeps the cache small regardless of what the fetch path hands
|
||||
// us. Jimp auto-applies EXIF orientation on read. Falls back to the original bytes
|
||||
// on any failure (corrupt/unsupported format) so behaviour is never worse than before.
|
||||
async function downscale(bytes: Buffer): Promise<Buffer> {
|
||||
try {
|
||||
const img = await Jimp.read(bytes);
|
||||
if (img.bitmap.width <= MAX_DIM && img.bitmap.height <= MAX_DIM) return bytes;
|
||||
img.scaleToFit({ w: MAX_DIM, h: MAX_DIM });
|
||||
return await img.getBuffer(JimpMime.jpeg, { quality: JPEG_QUALITY });
|
||||
} catch {
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
const resized = await downscale(bytes);
|
||||
await fsPromises.writeFile(tmp, resized);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)',
|
||||
).run(placeId, attribution, Date.now());
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution };
|
||||
}
|
||||
|
||||
export function getInFlight(placeId: string): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
export function getInFlight(
|
||||
placeId: string,
|
||||
): Promise<{ filePath: string; attribution: string | null } | null> | undefined {
|
||||
return inFlight.get(placeId);
|
||||
}
|
||||
|
||||
export function setInFlight(placeId: string, promise: Promise<{ filePath: string; attribution: string | null } | null>): void {
|
||||
export function setInFlight(
|
||||
placeId: string,
|
||||
promise: Promise<{ filePath: string; attribution: string | null } | null>,
|
||||
): void {
|
||||
inFlight.set(placeId, promise);
|
||||
promise
|
||||
.finally(() => inFlight.delete(placeId))
|
||||
.catch(() => { /* awaiter logs; this .catch only prevents unhandledRejection */ });
|
||||
.catch(() => {
|
||||
/* awaiter logs; this .catch only prevents unhandledRejection */
|
||||
});
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
@@ -108,3 +143,67 @@ export function serveFilePath(placeId: string): string | null {
|
||||
knownOnDisk.add(placeId);
|
||||
return fp;
|
||||
}
|
||||
|
||||
// A cache entry is "referenced" while any place still points at it — either by the
|
||||
// Google place_id (the dedup key) or by the stable proxy URL stored in image_url
|
||||
// (covers coords: pseudo-ids, which never have a google_place_id).
|
||||
function isReferenced(placeId: string): boolean {
|
||||
const row = db
|
||||
.prepare('SELECT 1 FROM places WHERE google_place_id = ? OR image_url = ? LIMIT 1')
|
||||
.get(placeId, proxyUrl(placeId));
|
||||
return !!row;
|
||||
}
|
||||
|
||||
function deleteEntry(placeId: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath(placeId));
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
knownOnDisk.delete(placeId);
|
||||
}
|
||||
|
||||
// Drop a cache entry if no place references it anymore. Called after a place delete
|
||||
// for prompt reclamation; the nightly sweep is the catch-all for every other path.
|
||||
export function removeIfUnreferenced(placeId: string): void {
|
||||
if (isReferenced(placeId)) return;
|
||||
deleteEntry(placeId);
|
||||
}
|
||||
|
||||
// Reclaim orphaned cache files + meta rows. Runs on startup and nightly (scheduler).
|
||||
// Two passes: (1) meta rows no place references; (2) stray .jpg files with no meta row.
|
||||
export function sweepOrphans(): number {
|
||||
let removed = 0;
|
||||
|
||||
const rows = db.prepare('SELECT place_id FROM google_place_photo_meta').all() as { place_id: string }[];
|
||||
const keepFiles = new Set<string>();
|
||||
for (const { place_id } of rows) {
|
||||
if (isReferenced(place_id)) {
|
||||
keepFiles.add(`${crypto.createHash('sha1').update(place_id).digest('hex')}.jpg`);
|
||||
} else {
|
||||
deleteEntry(place_id);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: files on disk that no surviving meta row maps to (e.g. left over from a
|
||||
// crash between writeFile and the DB upsert, or a meta row deleted out-of-band).
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(GOOGLE_PHOTO_DIR);
|
||||
} catch {
|
||||
entries = [];
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.jpg') || keepFiles.has(entry)) continue;
|
||||
try {
|
||||
fs.unlinkSync(path.join(GOOGLE_PHOTO_DIR, entry));
|
||||
removed++;
|
||||
} catch {
|
||||
/* race */
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,20 @@ import {
|
||||
type KmlImportSummary,
|
||||
} from './kmlImport';
|
||||
import { enrichImportedPlaces, type EnrichablePlace } from './placeEnrichment';
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// Reclaim a deleted place's cached marker photo if nothing else references it.
|
||||
// The cache key is the Google place_id, or — for coordinate-only places — the
|
||||
// pseudo-id embedded in the stored proxy URL (/api/maps/place-photo/{id}/bytes).
|
||||
function reclaimPhotoCache(googlePlaceId: string | null, imageUrl: string | null): void {
|
||||
const candidates = new Set<string>();
|
||||
if (googlePlaceId) candidates.add(googlePlaceId);
|
||||
const m = imageUrl?.match(/^\/api\/maps\/place-photo\/(.+)\/bytes$/);
|
||||
if (m) { try { candidates.add(decodeURIComponent(m[1])); } catch { /* malformed url */ } }
|
||||
for (const id of candidates) {
|
||||
try { placePhotoCache.removeIfUnreferenced(id); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Opt-in Places-API enrichment for list imports (#886). */
|
||||
export interface ListImportOptions {
|
||||
@@ -242,25 +256,33 @@ export function updatePlace(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function deletePlace(tripId: string, placeId: string): boolean {
|
||||
const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId);
|
||||
const place = db.prepare(
|
||||
'SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?'
|
||||
).get(placeId, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!place) return false;
|
||||
db.prepare('DELETE FROM places WHERE id = ?').run(placeId);
|
||||
reclaimPhotoCache(place.google_place_id, place.image_url);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
if (ids.length === 0) return [];
|
||||
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
||||
const selectStmt = db.prepare('SELECT google_place_id, image_url FROM places WHERE id = ? AND trip_id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleted: number[] = [];
|
||||
const reclaimable: { google_place_id: string | null; image_url: string | null }[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
if (!selectStmt.get(id, tripId)) continue;
|
||||
const row = selectStmt.get(id, tripId) as { google_place_id: string | null; image_url: string | null } | undefined;
|
||||
if (!row) continue;
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
reclaimable.push(row);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
// Reclaim after the transaction commits so isReferenced() sees the final place set.
|
||||
for (const row of reclaimable) reclaimPhotoCache(row.google_place_id, row.image_url);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ function isAlwaysBlocked(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// Loopback
|
||||
if (addr.startsWith("127.") || addr === '::1') return true;
|
||||
if (addr.startsWith('127.') || addr === '::1') return true;
|
||||
// Unspecified
|
||||
if (addr.startsWith("0.")) return true;
|
||||
if (addr.startsWith('0.')) return true;
|
||||
// Link-local / cloud metadata
|
||||
if (addr.startsWith("169.254.") || /^fe80:/i.test(addr)) return true;
|
||||
if (addr.startsWith('169.254.') || /^fe80:/i.test(addr)) return true;
|
||||
// IPv4-mapped loopback / link-local: ::ffff:127.x.x.x, ::ffff:169.254.x.x
|
||||
if (/^::ffff:127\./i.test(addr) || /^::ffff:169\.254\./i.test(addr)) return true;
|
||||
|
||||
@@ -32,9 +32,9 @@ function isPrivateNetwork(ip: string): boolean {
|
||||
const addr = ip.startsWith('[') ? ip.slice(1, -1) : ip;
|
||||
|
||||
// RFC-1918 private ranges
|
||||
if (addr.startsWith("10.")) return true;
|
||||
if (addr.startsWith('10.')) return true;
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(addr)) return true;
|
||||
if (addr.startsWith("192.168.")) return true;
|
||||
if (addr.startsWith('192.168.')) return true;
|
||||
// CGNAT / Tailscale shared address space (100.64.0.0/10)
|
||||
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(addr)) return true;
|
||||
// IPv6 ULA (fc00::/7)
|
||||
@@ -71,8 +71,9 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
try {
|
||||
const result = await dns.lookup(hostname);
|
||||
resolvedIp = result.address;
|
||||
} catch {
|
||||
return { allowed: false, isPrivate: false, error: 'Could not resolve hostname' };
|
||||
} catch (error_) {
|
||||
const code = error_ instanceof Error && 'code' in error_ ? String(error_.code) : 'unknown';
|
||||
return { allowed: false, isPrivate: false, error: `Could not resolve hostname (${code})` };
|
||||
}
|
||||
|
||||
if (isAlwaysBlocked(resolvedIp)) {
|
||||
@@ -90,7 +91,8 @@ export async function checkSsrf(rawUrl: string, bypassInternalIpAllowed: boolean
|
||||
allowed: false,
|
||||
isPrivate: true,
|
||||
resolvedIp,
|
||||
error: 'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
error:
|
||||
'Requests to private/internal network addresses are not allowed. Set ALLOW_INTERNAL_NETWORK=true to permit this for self-hosted setups.',
|
||||
};
|
||||
}
|
||||
return { allowed: true, isPrivate: true, resolvedIp };
|
||||
@@ -187,7 +189,7 @@ export async function safeFetchFollow(
|
||||
// (2xx/4xx/5xx, or a 3xx with no Location) is the final response.
|
||||
const status = typeof response.status === 'number' ? response.status : 0;
|
||||
const isRedirectStatus = status >= 300 && status < 400;
|
||||
const location = isRedirectStatus ? response.headers?.get('location') ?? null : null;
|
||||
const location = isRedirectStatus ? (response.headers?.get('location') ?? null) : null;
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,28 @@ describe('Auth e2e (real auth guard + real cookie service + temp SQLite)', () =>
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ vi.mock('../../../src/services/notificationService', () => ({ send: vi.fn().mock
|
||||
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;
|
||||
@@ -121,6 +122,114 @@ describe('AdminController addons + sessions + jwt + defaults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -132,4 +241,23 @@ describe('AdminController dev test-notification', () => {
|
||||
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' } });
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user