diff --git a/client/src/components/Dashboard/CurrencyWidget.tsx b/client/src/components/Dashboard/CurrencyWidget.tsx deleted file mode 100644 index dbb841ff..00000000 --- a/client/src/components/Dashboard/CurrencyWidget.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import { ArrowRightLeft, RefreshCw } from 'lucide-react' -import { useTranslation } from '../../i18n' -import CustomSelect from '../shared/CustomSelect' - -const CURRENCIES = [ - 'AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', - 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLF', 'CLP', - 'CNH', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', - 'FJD', 'FKP', 'FOK', 'GBP', 'GEL', 'GGP', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', - 'HTG', 'HUF', 'IDR', 'ILS', 'IMP', 'INR', 'IQD', 'ISK', 'JEP', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', - 'KID', 'KMF', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', - 'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', - 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', - 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLE', 'SOS', 'SRD', 'SSP', 'STN', 'SYP', 'SZL', 'THB', - 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TVD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VES', - 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XDR', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMW', 'ZWL' -] - -const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c })) - -export default function CurrencyWidget() { - const { t, locale } = useTranslation() - const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR') - const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD') - const [amount, setAmount] = useState('100') - const [rate, setRate] = useState(null) - const [loading, setLoading] = useState(false) - - const fetchRate = useCallback(async () => { - if (from === to) { setRate(1); return } - setLoading(true) - try { - const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`) - const data = await resp.json() - setRate(data.rates?.[to] || null) - } catch { setRate(null) } - finally { setLoading(false) } - }, [from, to]) - - useEffect(() => { fetchRate() }, [fetchRate]) - useEffect(() => { localStorage.setItem('currency_from', from) }, [from]) - useEffect(() => { localStorage.setItem('currency_to', to) }, [to]) - - const swap = () => { setFrom(to); setTo(from) } - const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null - const formatNumber = (num) => { - if (!num || num === '—') return '—' - return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) - } - const result = rawResult - - return ( -
-
- {t('dashboard.currency')} - -
- - {/* Amount */} -
- setAmount(e.target.value)} - className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none" - style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }} - /> -
- - {/* From / Swap / To */} -
-
- -
- -
- -
-
- - {/* Result */} -
-

- {formatNumber(result)} {to} -

- {rate &&

1 {from} = {rate.toFixed(4)} {to}

} -
-
- ) -} diff --git a/client/src/components/Dashboard/TimezoneWidget.test.tsx b/client/src/components/Dashboard/TimezoneWidget.test.tsx deleted file mode 100644 index 8991728e..00000000 --- a/client/src/components/Dashboard/TimezoneWidget.test.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' -import { render, screen } from '../../../tests/helpers/render' -import userEvent from '@testing-library/user-event' -import { resetAllStores, seedStore } from '../../../tests/helpers/store' -import { useSettingsStore } from '../../store/settingsStore' -import TimezoneWidget from './TimezoneWidget' - -beforeEach(() => { - resetAllStores() - vi.clearAllMocks() - localStorage.clear() - seedStore(useSettingsStore, { settings: { time_format: '24h' } } as any) -}) - -describe('TimezoneWidget', () => { - it('FE-COMP-TIMEZONE-001: renders without crashing with default zones', () => { - render() - expect(document.body).toBeInTheDocument() - expect(screen.getByText('New York')).toBeInTheDocument() - expect(screen.getByText('Tokyo')).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-002: shows local time text', () => { - render() - const timeElements = screen.getAllByText(/\d{1,2}:\d{2}/) - expect(timeElements.length).toBeGreaterThan(0) - }) - - it('FE-COMP-TIMEZONE-003: shows timezone section label', () => { - render() - expect(screen.getByText(/timezones/i)).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-004: default zones render on first load (no localStorage)', () => { - localStorage.clear() - render() - expect(screen.getByText('New York')).toBeInTheDocument() - expect(screen.getByText('Tokyo')).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-005: zones saved in localStorage are restored', () => { - localStorage.setItem('dashboard_timezones', JSON.stringify([{ label: 'Berlin', tz: 'Europe/Berlin' }])) - render() - expect(screen.getByText('Berlin')).toBeInTheDocument() - expect(screen.queryByText('New York')).toBeNull() - }) - - it('FE-COMP-TIMEZONE-006: clicking the Plus button opens the add-zone panel', async () => { - const user = userEvent.setup() - render() - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - expect(await screen.findByText('Custom Timezone')).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-007: adding a popular zone from the dropdown adds it to the list', async () => { - const user = userEvent.setup() - render() - // Open add panel - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - // Find and click Berlin in the popular zones list - const berlinButton = await screen.findByRole('button', { name: /Berlin/i }) - await user.click(berlinButton) - expect(screen.getByText('Berlin')).toBeInTheDocument() - // Panel should be closed - expect(screen.queryByText('Custom Timezone')).toBeNull() - }) - - it('FE-COMP-TIMEZONE-008: adding a custom valid timezone with label shows in the list', async () => { - const user = userEvent.setup() - render() - // Open add panel - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - // Type label and timezone - const labelInput = screen.getByPlaceholderText('Label (optional)') - const tzInput = screen.getByPlaceholderText('e.g. America/New_York') - await user.type(labelInput, 'My City') - await user.type(tzInput, 'Europe/Paris') - // Click Add - const addButton = screen.getByRole('button', { name: 'Add' }) - await user.click(addButton) - expect(await screen.findByText('My City')).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-009: adding a custom invalid timezone shows an error', async () => { - const user = userEvent.setup() - render() - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - const tzInput = screen.getByPlaceholderText('e.g. America/New_York') - await user.type(tzInput, 'Invalid/Timezone') - const addButton = screen.getByRole('button', { name: 'Add' }) - await user.click(addButton) - expect(await screen.findByText(/invalid timezone/i)).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-010: adding a duplicate timezone shows a duplicate error', async () => { - const user = userEvent.setup() - render() - // Default zones include New York (America/New_York) - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - const tzInput = screen.getByPlaceholderText('e.g. America/New_York') - await user.type(tzInput, 'America/New_York') - const addButton = screen.getByRole('button', { name: 'Add' }) - await user.click(addButton) - expect(await screen.findByText(/already added/i)).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-011: remove button removes a zone from the list', async () => { - const user = userEvent.setup() - render() - expect(screen.getByText('New York')).toBeInTheDocument() - // The remove buttons are always in the DOM (opacity-0 in CSS, not hidden from DOM) - // There are 2 zone rows (New York, Tokyo), plus the Plus button = 3 buttons total - // Remove buttons for New York and Tokyo come after the Plus button - const allButtons = screen.getAllByRole('button') - // allButtons[0] = Plus, allButtons[1] = remove New York, allButtons[2] = remove Tokyo - await user.click(allButtons[1]) - expect(screen.queryByText('New York')).toBeNull() - expect(screen.getByText('Tokyo')).toBeInTheDocument() - }) - - it('FE-COMP-TIMEZONE-012: adding a zone persists to localStorage', async () => { - const user = userEvent.setup() - render() - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - const berlinButton = await screen.findByRole('button', { name: /Berlin/i }) - await user.click(berlinButton) - const saved = JSON.parse(localStorage.getItem('dashboard_timezones') || '[]') - expect(saved.some((z: { tz: string }) => z.tz === 'Europe/Berlin')).toBe(true) - }) - - it('FE-COMP-TIMEZONE-013: Enter key in custom tz input triggers addCustomZone', async () => { - const user = userEvent.setup() - render() - const allButtons = screen.getAllByRole('button') - await user.click(allButtons[0]) - const labelInput = screen.getByPlaceholderText('Label (optional)') - const tzInput = screen.getByPlaceholderText('e.g. America/New_York') - await user.type(labelInput, 'Singapore') - await user.type(tzInput, 'Asia/Singapore') - await user.keyboard('{Enter}') - expect(await screen.findByText('Singapore')).toBeInTheDocument() - }) -}) diff --git a/client/src/components/Dashboard/TimezoneWidget.tsx b/client/src/components/Dashboard/TimezoneWidget.tsx deleted file mode 100644 index f3b3a8e7..00000000 --- a/client/src/components/Dashboard/TimezoneWidget.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { useState, useEffect } from 'react' -import { Clock, Plus, X } from 'lucide-react' -import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' - -const POPULAR_ZONES = [ - { label: 'New York', tz: 'America/New_York' }, - { label: 'London', tz: 'Europe/London' }, - { label: 'Berlin', tz: 'Europe/Berlin' }, - { label: 'Paris', tz: 'Europe/Paris' }, - { label: 'Dubai', tz: 'Asia/Dubai' }, - { label: 'Mumbai', tz: 'Asia/Kolkata' }, - { label: 'Bangkok', tz: 'Asia/Bangkok' }, - { label: 'Tokyo', tz: 'Asia/Tokyo' }, - { label: 'Sydney', tz: 'Australia/Sydney' }, - { label: 'Los Angeles', tz: 'America/Los_Angeles' }, - { label: 'Chicago', tz: 'America/Chicago' }, - { label: 'São Paulo', tz: 'America/Sao_Paulo' }, - { label: 'Istanbul', tz: 'Europe/Istanbul' }, - { label: 'Singapore', tz: 'Asia/Singapore' }, - { label: 'Hong Kong', tz: 'Asia/Hong_Kong' }, - { label: 'Seoul', tz: 'Asia/Seoul' }, - { label: 'Moscow', tz: 'Europe/Moscow' }, - { label: 'Cairo', tz: 'Africa/Cairo' }, -] - -function getTime(tz, locale, is12h) { - try { - return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h }) - } catch { return '—' } -} - -function getOffset(tz) { - try { - const now = new Date() - const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone })) - const remote = new Date(now.toLocaleString('en-US', { timeZone: tz })) - const diff = (remote - local) / 3600000 - const sign = diff >= 0 ? '+' : '' - return `${sign}${diff}h` - } catch { return '' } -} - -export default function TimezoneWidget() { - const { t, locale } = useTranslation() - const is12h = useSettingsStore(s => s.settings.time_format) === '12h' - const [zones, setZones] = useState(() => { - const saved = localStorage.getItem('dashboard_timezones') - return saved ? JSON.parse(saved) : [ - { label: 'New York', tz: 'America/New_York' }, - { label: 'Tokyo', tz: 'Asia/Tokyo' }, - ] - }) - const [now, setNow] = useState(Date.now()) - const [showAdd, setShowAdd] = useState(false) - const [customLabel, setCustomLabel] = useState('') - const [customTz, setCustomTz] = useState('') - const [customError, setCustomError] = useState('') - - useEffect(() => { - const i = setInterval(() => setNow(Date.now()), 10000) - return () => clearInterval(i) - }, []) - - useEffect(() => { - localStorage.setItem('dashboard_timezones', JSON.stringify(zones)) - }, [zones]) - - const isValidTz = (tz: string) => { - try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false } - } - - const addCustomZone = () => { - const tz = customTz.trim() - if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return } - if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return } - if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return } - const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz - setZones([...zones, { label, tz }]) - setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false) - } - - const addZone = (zone) => { - if (!zones.find(z => z.tz === zone.tz)) { - setZones([...zones, zone]) - } - setShowAdd(false) - } - - const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz)) - - const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h }) - const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone - const localZone = rawZone.split('/').pop().replace(/_/g, ' ') - // Show abbreviated timezone name (e.g. CET, CEST, EST) - const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop() - - return ( -
-
- {t('dashboard.timezone')} - -
- - {/* Local time */} -
-

{localTime}

-

{localZone} ({tzAbbr}) · {t('dashboard.localTime')}

-
- - {/* Zone list */} -
- {zones.map(z => ( -
-
-

{getTime(z.tz, locale, is12h)}

-

{z.label} {getOffset(z.tz)}

-
- -
- ))} -
- - {/* Add zone dropdown */} - {showAdd && ( -
- {/* Custom timezone */} -
-

{t('dashboard.timezoneCustomTitle')}

-
- setCustomLabel(e.target.value)} - placeholder={t('dashboard.timezoneCustomLabelPlaceholder')} - className="w-full px-2 py-1.5 rounded-lg text-xs outline-none" - style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} /> - { setCustomTz(e.target.value); setCustomError('') }} - placeholder={t('dashboard.timezoneCustomTzPlaceholder')} - className="w-full px-2 py-1.5 rounded-lg text-xs outline-none" - style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }} - onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} /> - {customError &&

{customError}

} - -
-
- {/* Popular zones */} - {POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => ( - - ))} -
- )} -
- ) -} diff --git a/client/src/components/Layout/MobileTopHeader.test.tsx b/client/src/components/Layout/MobileTopHeader.test.tsx deleted file mode 100644 index b8adca13..00000000 --- a/client/src/components/Layout/MobileTopHeader.test.tsx +++ /dev/null @@ -1,32 +0,0 @@ -// FE-COMP-MOBILETOPHEADER-001 to FE-COMP-MOBILETOPHEADER-004 - -import { describe, it, expect } from 'vitest'; -import { render, screen } from '../../../tests/helpers/render'; -import MobileTopHeader from './MobileTopHeader'; - -describe('MobileTopHeader', () => { - it('FE-COMP-MOBILETOPHEADER-001: renders title as h1', () => { - render(); - const heading = screen.getByRole('heading', { level: 1 }); - expect(heading).toBeInTheDocument(); - expect(heading.textContent).toBe('Journeys'); - }); - - it('FE-COMP-MOBILETOPHEADER-002: renders subtitle when provided', () => { - render(); - expect(screen.getByText('3 trips')).toBeInTheDocument(); - }); - - it('FE-COMP-MOBILETOPHEADER-003: does not render subtitle when omitted', () => { - const { container } = render(); - const subtitleEl = container.querySelector('.text-xs.text-zinc-500'); - expect(subtitleEl).not.toBeInTheDocument(); - }); - - it('FE-COMP-MOBILETOPHEADER-004: renders action children when provided', () => { - render( - Add} />, - ); - expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); - }); -}); diff --git a/client/src/components/Layout/MobileTopHeader.tsx b/client/src/components/Layout/MobileTopHeader.tsx deleted file mode 100644 index 4f6f3052..00000000 --- a/client/src/components/Layout/MobileTopHeader.tsx +++ /dev/null @@ -1,17 +0,0 @@ -interface Props { - title: string - subtitle?: string - actions?: React.ReactNode -} - -export default function MobileTopHeader({ title, subtitle, actions }: Props) { - return ( -
-
-

{title}

- {subtitle &&
{subtitle}
} -
- {actions &&
{actions}
} -
- ) -} diff --git a/client/src/components/Memories/MemoriesPanel.test.tsx b/client/src/components/Memories/MemoriesPanel.test.tsx deleted file mode 100644 index cbb914a2..00000000 --- a/client/src/components/Memories/MemoriesPanel.test.tsx +++ /dev/null @@ -1,789 +0,0 @@ -// FE-COMP-MEMORIESPANEL-001 to FE-COMP-MEMORIESPANEL-027 -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { render } from '../../../tests/helpers/render'; -import { resetAllStores, seedStore } from '../../../tests/helpers/store'; -import { server } from '../../../tests/helpers/msw/server'; -import { http, HttpResponse } from 'msw'; -import { useAuthStore } from '../../store/authStore'; -import { buildUser } from '../../../tests/helpers/factories'; -import MemoriesPanel from './MemoriesPanel'; - -// Mock fetchImageAsBlob to avoid real HTTP calls for thumbnail/image rendering -vi.mock('../../api/authUrl', () => ({ - fetchImageAsBlob: vi.fn().mockResolvedValue('blob:mock-url'), - clearImageQueue: vi.fn(), -})); - -const defaultProps = { - tripId: 1, - startDate: '2025-03-01', - endDate: '2025-03-10', -}; - -// Reusable provider object to configure a connected Immich instance -const immichAddon = { - id: 'immich', - name: 'Immich', - type: 'photo_provider', - enabled: true, - config: { status_get: '/integrations/memories/immich/status' }, -}; - -// Handlers that simulate a connected provider with no photos/links -const connectedHandlers = [ - http.get('/api/addons', () => - HttpResponse.json({ addons: [immichAddon] }) - ), - http.get('/api/integrations/memories/immich/status', () => - HttpResponse.json({ connected: true }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ photos: [] }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ links: [] }) - ), -]; - -beforeEach(() => { - resetAllStores(); - // Seed a default logged-in user - seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'me' }) }); -}); - -describe('MemoriesPanel', () => { - it('FE-COMP-MEMORIESPANEL-001: Shows loading state on initial render', () => { - // Use a delayed response so loading stays true long enough to assert - server.use( - http.get('/api/addons', async () => { - await new Promise(resolve => setTimeout(resolve, 200)); - return HttpResponse.json({ addons: [] }); - }), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ photos: [] }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ links: [] }) - ), - ); - - render(); - - // Spinner is rendered synchronously — loading state starts as true - expect(document.querySelector('.animate-spin')).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-002: Shows not-connected state when no photo providers are enabled', async () => { - server.use( - http.get('/api/addons', () => HttpResponse.json({ addons: [] })), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ photos: [] }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ links: [] }) - ), - ); - - render(); - - // "Photo provider not connected" — no providers, falls back to generic label - await screen.findByText('Photo provider not connected'); - }); - - it('FE-COMP-MEMORIESPANEL-003: Displays trip photos from other users', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { - asset_id: 'abc', - provider: 'immich', - user_id: 2, - username: 'Alice', - shared: 1, - added_at: '2025-03-05T10:00:00Z', - }, - ], - }) - ), - ); - - render(); - - // Alice's username is rendered as an avatar tooltip in the gallery - await screen.findByText('Alice'); - }); - - it('FE-COMP-MEMORIESPANEL-004: Shows empty gallery state when connected but no photos', async () => { - server.use(...connectedHandlers); - - render(); - - // Provider is connected so the gallery renders — but no photos → empty state - await screen.findByText('No photos found'); - }); - - it('FE-COMP-MEMORIESPANEL-005: Album links are displayed in the gallery header', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ - links: [ - { - id: 1, - provider: 'immich', - album_id: 'a1', - album_name: 'Holidays', - user_id: 1, - username: 'me', - sync_enabled: 1, - last_synced_at: null, - }, - ], - }) - ), - ); - - render(); - - await screen.findByText('Holidays'); - }); - - it('FE-COMP-MEMORIESPANEL-006: Sync button calls the sync endpoint', async () => { - let syncCalled = false; - - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ - links: [ - { - id: 1, - provider: 'immich', - album_id: 'a1', - album_name: 'Holidays', - user_id: 1, - username: 'me', - sync_enabled: 1, - last_synced_at: null, - }, - ], - }) - ), - http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => { - syncCalled = true; - return HttpResponse.json({ ok: true }); - }), - ); - - render(); - - await screen.findByText('Holidays'); - - const syncBtn = screen.getByTitle('Sync album'); - await userEvent.click(syncBtn); - - await waitFor(() => expect(syncCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-007: Unlink button calls the delete endpoint', async () => { - let deleteCalled = false; - - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ - links: [ - { - id: 1, - provider: 'immich', - album_id: 'a1', - album_name: 'Holidays', - user_id: 1, - username: 'me', - sync_enabled: 1, - last_synced_at: null, - }, - ], - }) - ), - http.delete('/api/integrations/memories/unified/trips/:tripId/album-links/:linkId', () => { - deleteCalled = true; - return HttpResponse.json({ ok: true }); - }), - ); - - render(); - - await screen.findByText('Holidays'); - - // The unlink button is only shown when link.user_id === currentUser.id - const unlinkBtn = screen.getByTitle('Unlink album'); - await userEvent.click(unlinkBtn); - - await waitFor(() => expect(deleteCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-008: Sort toggle switches between oldest-first and newest-first', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { photo_id: 1, asset_id: 'photo1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T10:00:00Z' }, - { photo_id: 2, asset_id: 'photo2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-10T10:00:00Z' }, - ], - }) - ), - ); - - render(); - - // Default sort is ascending ("Oldest first") - const sortBtn = await screen.findByText('Oldest first'); - - await userEvent.click(sortBtn); - - // After toggle, button label switches to "Newest first" - expect(screen.getByText('Newest first')).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-009: Photo picker opens when "Add photos" is clicked', async () => { - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ assets: [] }) - ), - ); - - render(); - - // Wait for the empty gallery to load - await screen.findByText('No photos found'); - - // Both the header button and gallery CTA say "Add photos" — click the first - const addBtns = screen.getAllByText('Add photos'); - await userEvent.click(addBtns[0]); - - // Picker header is now visible - await screen.findByText('Select photos from Immich'); - }); - - it('FE-COMP-MEMORIESPANEL-010: Picker cancel button closes the picker', async () => { - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ assets: [] }) - ), - ); - - render(); - - await screen.findByText('No photos found'); - - const addBtns = screen.getAllByText('Add photos'); - await userEvent.click(addBtns[0]); - await screen.findByText('Select photos from Immich'); - - // Click Cancel in the picker header - await userEvent.click(screen.getByText('Cancel')); - - // Gallery is restored - await screen.findByText('No photos found'); - }); - - it('FE-COMP-MEMORIESPANEL-011: Album picker opens when "Link Album" is clicked', async () => { - server.use( - ...connectedHandlers, - http.get('/api/integrations/memories/immich/albums', () => - HttpResponse.json({ albums: [] }) - ), - ); - - render(); - - await screen.findByText('No photos found'); - - await userEvent.click(screen.getByText('Link Album')); - - // Album picker header appears - await screen.findByText('Select Immich Album'); - }); - - it('FE-COMP-MEMORIESPANEL-012: Own photos render with share-toggle and private indicator', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { - asset_id: 'photo1', - provider: 'immich', - user_id: 1, - username: 'me', - shared: 0, - added_at: '2025-03-05T10:00:00Z', - }, - ], - }) - ), - ); - - render(); - - // Share-toggle button appears with correct title (not shared → "Share photos") - await screen.findByTitle('Share photos'); - - // "Private" label is shown on unshared own photos - expect(screen.getByText('Private')).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-013: toggleSharing calls the PUT sharing endpoint', async () => { - let putCalled = false; - - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { - asset_id: 'photo1', - provider: 'immich', - user_id: 1, - username: 'me', - shared: 0, - added_at: '2025-03-05T10:00:00Z', - }, - ], - }) - ), - http.put('/api/integrations/memories/unified/trips/:tripId/photos/sharing', () => { - putCalled = true; - return HttpResponse.json({ ok: true }); - }), - ); - - render(); - - const shareBtn = await screen.findByTitle('Share photos'); - await userEvent.click(shareBtn); - - await waitFor(() => expect(putCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-014: removePhoto calls the DELETE photos endpoint', async () => { - let deleteCalled = false; - - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { - asset_id: 'photo1', - provider: 'immich', - user_id: 1, - username: 'me', - shared: 1, - added_at: '2025-03-05T10:00:00Z', - }, - ], - }) - ), - http.delete('/api/integrations/memories/unified/trips/:tripId/photos', () => { - deleteCalled = true; - return HttpResponse.json({ ok: true }); - }), - ); - - render(); - - // Wait for the share/stop-sharing button to confirm the gallery has rendered - await screen.findByTitle('Stop sharing'); - - // The remove button is the second action button in the hover overlay — no title, just an X icon - // Get all buttons and click the one after the share toggle - const allBtns = screen.getAllByRole('button'); - const shareIdx = allBtns.findIndex(b => b.getAttribute('title') === 'Stop sharing'); - // The remove button immediately follows the share button in the DOM - await userEvent.click(allBtns[shareIdx + 1]); - - await waitFor(() => expect(deleteCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-015: Picker displays assets grouped by month', async () => { - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ - assets: [ - { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: 'Paris', country: 'France' }, - ], - }) - ), - ); - - render(); - await screen.findByText('No photos found'); - - const [firstAddBtn] = screen.getAllByText('Add photos'); - await userEvent.click(firstAddBtn); - - await screen.findByText('Select photos from Immich'); - - // Month group header appears after photos load - await screen.findByText(/March.*2025|2025.*March/); - }); - - it('FE-COMP-MEMORIESPANEL-016: Album picker lists available albums with asset count', async () => { - server.use( - ...connectedHandlers, - http.get('/api/integrations/memories/immich/albums', () => - HttpResponse.json({ - albums: [ - { id: 'album1', albumName: 'Summer 2025', assetCount: 42 }, - ], - }) - ), - ); - - render(); - await screen.findByText('No photos found'); - - await userEvent.click(screen.getByText('Link Album')); - - await screen.findByText('Summer 2025'); - // Asset count is rendered next to the album name - expect(screen.getByText(/42/)).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-017: ProviderTabs appear in picker when multiple providers are connected', async () => { - const immich2Addon = { - id: 'immich2', - name: 'Immich2', - type: 'photo_provider', - enabled: true, - config: { status_get: '/integrations/memories/immich2/status' }, - }; - - server.use( - http.get('/api/addons', () => - HttpResponse.json({ addons: [immichAddon, immich2Addon] }) - ), - http.get('/api/integrations/memories/immich/status', () => HttpResponse.json({ connected: true })), - http.get('/api/integrations/memories/immich2/status', () => HttpResponse.json({ connected: true })), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => HttpResponse.json({ photos: [] })), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => HttpResponse.json({ links: [] })), - http.post('/api/integrations/memories/immich/search', () => HttpResponse.json({ assets: [] })), - http.post('/api/integrations/memories/immich2/search', () => HttpResponse.json({ assets: [] })), - ); - - render(); - await screen.findByText('No photos found'); - - const [firstAddBtn] = screen.getAllByText('Add photos'); - await userEvent.click(firstAddBtn); - - // With multiple providers the picker header uses the "multiple" translation - await screen.findByText('Select Photos'); - - // Both provider name tabs are rendered inside the picker - expect(screen.getByRole('button', { name: 'Immich' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Immich2' })).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-018: Location filter dropdown appears when photos have multiple cities', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, - { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' }, - ], - }) - ), - ); - - render(); - - // Location dropdown shows "All locations" option when there are 2+ distinct cities - await screen.findByText('All locations'); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-019: Full picker flow: select photo → confirm dialog → execute add', async () => { - let addPhotosCalled = false; - - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ - assets: [ - { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }, - ], - }) - ), - http.post('/api/integrations/memories/unified/trips/:tripId/photos', () => { - addPhotosCalled = true; - return HttpResponse.json({ ok: true }); - }), - ); - - render(); - await screen.findByText('No photos found'); - - const [firstAddBtn] = screen.getAllByText('Add photos'); - await userEvent.click(firstAddBtn); - - await screen.findByText('Select photos from Immich'); - - // Wait for the picker asset thumbnail to render (ProviderImg sets src after blob resolves) - // img has alt="" so findByRole('img') won't work — use findByAltText instead - const thumbnail = await screen.findByAltText(''); - - // Click the thumbnail — bubbles up to the parent div's onClick to select it - await userEvent.click(thumbnail); - - // "1 selected" count appears and "Add 1 photos" button is active - await screen.findByText(/1\s+selected/); - await userEvent.click(screen.getByText('Add 1 photos')); - - // Confirm share dialog appears - await screen.findByText('Share with trip members?'); - - // Click the confirm "Share photos" button to execute - await userEvent.click(screen.getByText('Share photos')); - - await waitFor(() => expect(addPhotosCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-020: "All photos" filter tab makes an unfiltered search', async () => { - let searchCount = 0; - - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => { - searchCount++; - return HttpResponse.json({ assets: [] }); - }), - ); - - render(); - await screen.findByText('No photos found'); - - const [firstAddBtn] = screen.getAllByText('Add photos'); - await userEvent.click(firstAddBtn); - - await screen.findByText('Select photos from Immich'); - - // Click "All photos" — triggers a second loadPickerPhotos(false) call - await userEvent.click(screen.getByText('All photos')); - - await waitFor(() => expect(searchCount).toBeGreaterThan(1)); - }); - - it('FE-COMP-MEMORIESPANEL-021: Picker with no trip dates shows only "All photos" tab', async () => { - server.use( - ...connectedHandlers, - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ assets: [] }) - ), - ); - - render(); - - await screen.findByText('No photos found'); - - const [firstAddBtn] = screen.getAllByText('Add photos'); - await userEvent.click(firstAddBtn); - - await screen.findByText('Select photos from Immich'); - - // "Trip dates" tab is absent when dates are not set - expect(screen.queryByText(/Trip dates/)).not.toBeInTheDocument(); - expect(screen.getByText('All photos')).toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-022: Provider with no status_get URL shows not-connected', async () => { - server.use( - http.get('/api/addons', () => - HttpResponse.json({ - addons: [ - { id: 'myapp', name: 'MyApp', type: 'photo_provider', enabled: true, config: {} }, - ], - }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ photos: [] }) - ), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ links: [] }) - ), - ); - - render(); - - // Provider name shown in the not-connected message when exactly 1 enabled provider - await screen.findByText('MyApp not connected'); - }); - - it('FE-COMP-MEMORIESPANEL-023: Picker marks already-added photos with "Added" overlay', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { - asset_id: 'asset1', - provider: 'immich', - user_id: 1, - username: 'me', - shared: 1, - added_at: '2025-03-05T10:00:00Z', - }, - ], - }) - ), - http.post('/api/integrations/memories/immich/search', () => - HttpResponse.json({ - assets: [ - { id: 'asset1', takenAt: '2025-03-05T10:00:00Z', city: null, country: null }, - ], - }) - ), - ); - - render(); - - // Gallery shows own photo — "Stop sharing" title confirms it's loaded - await screen.findByTitle('Stop sharing'); - - // Open picker from the header button (only 1 "Add photos" button since photos > 0) - await userEvent.click(screen.getByText('Add photos')); - await screen.findByText('Select photos from Immich'); - - // The asset already in the gallery shows the "Added" overlay in the picker - await screen.findByText('Added'); - }); - - it('FE-COMP-MEMORIESPANEL-024: Location filter select filters the visible photos', async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('photos')), - http.get('/api/integrations/memories/unified/trips/:tripId/photos', () => - HttpResponse.json({ - photos: [ - { photo_id: 10, asset_id: 'p1', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-01T00:00:00Z', city: 'Paris' }, - { photo_id: 11, asset_id: 'p2', provider: 'immich', user_id: 1, username: 'me', shared: 1, added_at: '2025-03-05T00:00:00Z', city: 'Lyon' }, - ], - }) - ), - ); - - render(); - - const select = await screen.findByRole('combobox'); - - // Change filter to a specific city - await userEvent.selectOptions(select, 'Paris'); - - expect(select).toHaveValue('Paris'); - }); - - it("FE-COMP-MEMORIESPANEL-025: Album link from another user shows username but no unlink button", async () => { - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => - HttpResponse.json({ - links: [ - { - id: 1, - provider: 'immich', - album_id: 'a1', - album_name: 'Holidays', - user_id: 2, - username: 'Alice', - sync_enabled: 1, - last_synced_at: null, - }, - ], - }) - ), - ); - - render(); - - await screen.findByText('Holidays'); - - // Other user's username is shown in parentheses - expect(screen.getByText('(Alice)')).toBeInTheDocument(); - - // Unlink button is NOT shown for another user's album link - expect(screen.queryByTitle('Unlink album')).not.toBeInTheDocument(); - }); - - it('FE-COMP-MEMORIESPANEL-026: Linking an album calls the album-links POST endpoint', async () => { - let linkCalled = false; - // Track whether POST has been made so the GET can return different data - let albumLinked = false; - - server.use( - ...connectedHandlers.filter(h => !h.info.path.includes('album-links')), - http.get('/api/integrations/memories/immich/albums', () => - HttpResponse.json({ - albums: [{ id: 'album1', albumName: 'Summer 2025', assetCount: 10 }], - }) - ), - http.post('/api/integrations/memories/unified/trips/:tripId/album-links', () => { - linkCalled = true; - albumLinked = true; - return HttpResponse.json({ ok: true }); - }), - // Return empty before POST, linked album after POST - http.get('/api/integrations/memories/unified/trips/:tripId/album-links', () => { - if (!albumLinked) return HttpResponse.json({ links: [] }); - return HttpResponse.json({ - links: [{ id: 1, provider: 'immich', album_id: 'album1', album_name: 'Summer 2025', user_id: 1, username: 'me', sync_enabled: 1, last_synced_at: null }], - }); - }), - http.post('/api/integrations/memories/:provider/trips/:tripId/album-links/:linkId/sync', () => - HttpResponse.json({ ok: true }) - ), - ); - - render(); - await screen.findByText('No photos found'); - - await userEvent.click(screen.getByText('Link Album')); - await screen.findByText('Summer 2025'); - - // Click the album button to link it (album is not yet linked → button is enabled) - await userEvent.click(screen.getByText('Summer 2025')); - - await waitFor(() => expect(linkCalled).toBe(true)); - }); - - it('FE-COMP-MEMORIESPANEL-027: Album picker cancel button returns to the gallery', async () => { - server.use( - ...connectedHandlers, - http.get('/api/integrations/memories/immich/albums', () => - HttpResponse.json({ albums: [] }) - ), - ); - - render(); - await screen.findByText('No photos found'); - - await userEvent.click(screen.getByText('Link Album')); - await screen.findByText('Select Immich Album'); - - // Click Cancel to dismiss without linking - await userEvent.click(screen.getByText('Cancel')); - - // Gallery is restored - await screen.findByText('No photos found'); - }); -}); diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx deleted file mode 100644 index 07da4828..00000000 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ /dev/null @@ -1,1169 +0,0 @@ -import { useState, useEffect, useCallback } from 'react' -import apiClient, { addonsApi } from '../../api/client' -import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter, Link2, RefreshCw, Unlink, FolderOpen, Info, ChevronLeft, ChevronRight } from 'lucide-react' -import { useAuthStore } from '../../store/authStore' -import { useTranslation } from '../../i18n' -import { fetchImageAsBlob, clearImageQueue } from '../../api/authUrl' -import { useToast } from '../shared/Toast' - -interface PhotoProvider { - id: string - name: string - icon?: string - config?: Record -} - -function ProviderImg({ baseUrl, provider, style, loading }: { baseUrl: string; provider: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { - const [src, setSrc] = useState('') - useEffect(() => { - let revoke = '' - fetchImageAsBlob('/api' + baseUrl).then(blobUrl => { - revoke = blobUrl - setSrc(blobUrl) - }) - return () => { if (revoke) URL.revokeObjectURL(revoke) } - }, [baseUrl]) - return src ? : null -} - - -// ── Types ─────────────────────────────────────────────────────────────────── - -interface TripPhoto { - photo_id: number - asset_id: string - provider: string - user_id: number - username: string - shared: number - added_at: string - city?: string | null -} - -interface Asset { - id: string - provider: string - takenAt: string - city: string | null - country: string | null -} - -interface MemoriesPanelProps { - tripId: number - startDate: string | null - endDate: string | null -} - -// ── State + data ────────────────────────────────────────────────────────── - -/** - * Memories state: provider connection/discovery, the saved trip photos, the - * photo + album pickers, filters/sort and the lightbox (with EXIF info). All - * URL building and API calls live here so the views below stay presentational. - */ -function useMemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) { - const { t } = useTranslation() - const toast = useToast() - const currentUser = useAuthStore(s => s.user) - - const [connected, setConnected] = useState(false) - const [enabledProviders, setEnabledProviders] = useState([]) - const [availableProviders, setAvailableProviders] = useState([]) - const [selectedProvider, setSelectedProvider] = useState('') - const [loading, setLoading] = useState(true) - - // Trip photos (saved selections) - const [tripPhotos, setTripPhotos] = useState([]) - - // Photo picker - const [showPicker, setShowPicker] = useState(false) - const [pickerPhotos, setPickerPhotos] = useState([]) - const [pickerLoading, setPickerLoading] = useState(false) - const [selectedIds, setSelectedIds] = useState>(new Set()) - - // Confirm share popup - const [showConfirmShare, setShowConfirmShare] = useState(false) - - // Filters & sort - const [sortAsc, setSortAsc] = useState(true) - const [locationFilter, setLocationFilter] = useState('') - - // Album linking - const [showAlbumPicker, setShowAlbumPicker] = useState(false) - const [albums, setAlbums] = useState<{ id: string; albumName: string; assetCount: number; passphrase?: string }[]>([]) - const [albumsLoading, setAlbumsLoading] = useState(false) - const [albumLinks, setAlbumLinks] = useState<{ id: number; provider: string; album_id: string; album_name: string; user_id: number; username: string; sync_enabled: number; last_synced_at: string | null }[]>([]) - const [syncing, setSyncing] = useState(null) - - //helpers for building urls - const ADDON_PREFIX = "/integrations/memories" - - function buildUnifiedUrl(endpoint: string, lastParam?:string,): string { - return `${ADDON_PREFIX}/unified/trips/${tripId}/${endpoint}${lastParam ? `/${lastParam}` : ''}`; - } - - function buildProviderUrl(provider: string, endpoint: string, item?: string): string { - if (endpoint === 'album-link-sync') { - endpoint = `trips/${tripId}/album-links/${item?.toString() || ''}/sync` - } - return `${ADDON_PREFIX}/${provider}/${endpoint}`; - } - - function buildProviderAssetUrl(photo: TripPhoto, what: string): string { - return `/photos/${photo.photo_id}/${what}` - } - - function buildProviderAssetUrlFromAsset(asset: Asset, what: string, userId: number): string { - // Picker photos are not yet saved — use provider-specific URL - return `${ADDON_PREFIX}/${asset.provider}/assets/${tripId}/${asset.id}/${userId}/${what}` - } - - const loadAlbumLinks = async () => { - try { - const res = await apiClient.get(buildUnifiedUrl('album-links')) - setAlbumLinks(res.data.links || []) - } catch { setAlbumLinks([]) } - } - - const loadAlbums = async (provider: string = selectedProvider) => { - if (!provider) return - setAlbumsLoading(true) - try { - const res = await apiClient.get(buildProviderUrl(provider, 'albums')) - setAlbums(res.data.albums || []) - } catch { - setAlbums([]) - toast.error(t('memories.error.loadAlbums')) - } finally { - setAlbumsLoading(false) - } - } - - const openAlbumPicker = async () => { - setShowAlbumPicker(true) - await loadAlbums(selectedProvider) - } - - const linkAlbum = async (albumId: string, albumName: string, passphrase?: string) => { - if (!selectedProvider) { - toast.error(t('memories.error.linkAlbum')) - return - } - - try { - await apiClient.post(buildUnifiedUrl('album-links'), { - album_id: albumId, - album_name: albumName, - provider: selectedProvider, - ...(passphrase ? { passphrase } : {}), - }) - setShowAlbumPicker(false) - await loadAlbumLinks() - // Auto-sync after linking - const linksRes = await apiClient.get(buildUnifiedUrl('album-links')) - const newLink = (linksRes.data.links || []).find((l: any) => l.album_id === albumId && l.provider === selectedProvider) - if (newLink) await syncAlbum(newLink.id) - } catch { toast.error(t('memories.error.linkAlbum')) } - } - - const unlinkAlbum = async (linkId: number) => { - try { - await apiClient.delete(buildUnifiedUrl('album-links', linkId.toString())) - await loadAlbumLinks() - await loadPhotos() - } catch { toast.error(t('memories.error.unlinkAlbum')) } - } - - const syncAlbum = async (linkId: number, provider?: string) => { - const targetProvider = provider || selectedProvider - if (!targetProvider) return - setSyncing(linkId) - try { - await apiClient.post(buildProviderUrl(targetProvider, 'album-link-sync', linkId.toString())) - await loadAlbumLinks() - await loadPhotos() - } catch { toast.error(t('memories.error.syncAlbum')) } - finally { setSyncing(null) } - } - - // Lightbox - const [lightboxId, setLightboxId] = useState(null) - const [lightboxUserId, setLightboxUserId] = useState(null) - const [lightboxInfo, setLightboxInfo] = useState(null) - const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false) - const [lightboxOriginalSrc, setLightboxOriginalSrc] = useState('') - const [showMobileInfo, setShowMobileInfo] = useState(false) - const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768) - - useEffect(() => { - const handleResize = () => setIsMobile(window.innerWidth < 768) - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, []) - - // ── Init ────────────────────────────────────────────────────────────────── - - useEffect(() => { - loadInitial() - }, [tripId]) - - // WebSocket: reload photos when another user adds/removes/shares - useEffect(() => { - const handler = () => loadPhotos() - window.addEventListener('memories:updated', handler) - return () => window.removeEventListener('memories:updated', handler) - }, [tripId]) - - const loadPhotos = async () => { - try { - const photosRes = await apiClient.get(buildUnifiedUrl('photos')) - setTripPhotos(photosRes.data.photos || []) - } catch { - setTripPhotos([]) - } - } - - const loadInitial = async () => { - setLoading(true) - try { - const addonsRes = await addonsApi.enabled().catch(() => ({ addons: [] as any[] })) - const enabledAddons = addonsRes?.addons || [] - const photoProviders = enabledAddons.filter((a: any) => a.type === 'photo_provider' && a.enabled) - - setEnabledProviders(photoProviders.map((a: any) => ({ id: a.id, name: a.name, icon: a.icon, config: a.config }))) - - // Test connection status for each enabled provider - const statusResults = await Promise.all( - photoProviders.map(async (provider: any) => { - const statusUrl = (provider.config as Record)?.status_get as string - if (!statusUrl) return { provider, connected: false } - try { - const res = await apiClient.get(statusUrl) - return { provider, connected: !!res.data?.connected } - } catch { - return { provider, connected: false } - } - }) - ) - - const connectedProviders = statusResults - .filter(r => r.connected) - .map(r => ({ id: r.provider.id, name: r.provider.name, icon: r.provider.icon, config: r.provider.config })) - - setAvailableProviders(connectedProviders) - setConnected(connectedProviders.length > 0) - if (connectedProviders.length > 0 && !selectedProvider) { - setSelectedProvider(connectedProviders[0].id) - } - } catch { - setAvailableProviders([]) - setConnected(false) - } - await loadPhotos() - await loadAlbumLinks() - setLoading(false) - } - - // ── Photo Picker ────────────────────────────────────────────────────────── - - const [pickerDateFilter, setPickerDateFilter] = useState(true) - - const openPicker = async () => { - setShowPicker(true) - setPickerLoading(true) - setSelectedIds(new Set()) - setPickerDateFilter(!!(startDate && endDate)) - await loadPickerPhotos(!!(startDate && endDate)) - } - - useEffect(() => { - if (showPicker) { - loadPickerPhotos(pickerDateFilter) - } - }, [selectedProvider]) - - useEffect(() => { - loadAlbumLinks() - }, [tripId]) - - useEffect(() => { - if (showAlbumPicker) { - loadAlbums(selectedProvider) - } - }, [showAlbumPicker, selectedProvider, tripId]) - - const loadPickerPhotos = async (useDate: boolean) => { - setPickerLoading(true) - try { - const provider = availableProviders.find(p => p.id === selectedProvider) - if (!provider) { - setPickerPhotos([]) - return - } - const res = await apiClient.post(buildProviderUrl(provider.id, 'search'), { - from: useDate && startDate ? startDate : undefined, - to: useDate && endDate ? endDate : undefined, - }) - setPickerPhotos((res.data.assets || []).map((asset: Asset) => ({ ...asset, provider: provider.id }))) - } catch { - setPickerPhotos([]) - toast.error(t('memories.error.loadPhotos')) - } finally { - setPickerLoading(false) - } - } - - const togglePickerSelect = (id: string) => { - setSelectedIds(prev => { - const next = new Set(prev) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - } - - const confirmSelection = () => { - if (selectedIds.size === 0) return - setShowConfirmShare(true) - } - - const executeAddPhotos = async () => { - setShowConfirmShare(false) - try { - const groupedByProvider = new Map() - for (const key of selectedIds) { - const [provider, assetId] = key.split('::') - if (!provider || !assetId) continue - const list = groupedByProvider.get(provider) || [] - list.push(assetId) - groupedByProvider.set(provider, list) - } - - await apiClient.post(buildUnifiedUrl('photos'), { - selections: [...groupedByProvider.entries()].map(([provider, asset_ids]) => ({ provider, asset_ids })), - shared: true, - }) - setShowPicker(false) - clearImageQueue() - loadInitial() - } catch { toast.error(t('memories.error.addPhotos')) } - } - - // ── Remove photo ────────────────────────────────────────────────────────── - - const removePhoto = async (photo: TripPhoto) => { - try { - await apiClient.delete(buildUnifiedUrl('photos'), { - data: { - photo_id: photo.photo_id, - }, - }) - setTripPhotos(prev => prev.filter(p => p.photo_id !== photo.photo_id)) - } catch { toast.error(t('memories.error.removePhoto')) } - } - - // ── Toggle sharing ──────────────────────────────────────────────────────── - - const toggleSharing = async (photo: TripPhoto, shared: boolean) => { - try { - await apiClient.put(buildUnifiedUrl('photos', 'sharing'), { - shared, - photo_id: photo.photo_id, - }) - setTripPhotos(prev => prev.map(p => - p.photo_id === photo.photo_id ? { ...p, shared: shared ? 1 : 0 } : p - )) - } catch { toast.error(t('memories.error.toggleSharing')) } - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - const makePickerKey = (provider: string, assetId: string): string => `${provider}::${assetId}` - - const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id) - const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared) - const allVisibleRaw = [...ownPhotos, ...othersPhotos] - - // Unique locations for filter - const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort() - - // Apply filter + sort - const allVisible = allVisibleRaw - .filter(p => !locationFilter || p.city === locationFilter) - .sort((a, b) => { - const da = new Date(a.added_at || 0).getTime() - const db = new Date(b.added_at || 0).getTime() - return sortAsc ? da - db : db - da - }) - - const font: React.CSSProperties = { - fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", - } - - return { - t, currentUser, - connected, enabledProviders, availableProviders, selectedProvider, setSelectedProvider, loading, - tripPhotos, showPicker, setShowPicker, pickerPhotos, pickerLoading, selectedIds, - showConfirmShare, setShowConfirmShare, sortAsc, setSortAsc, locationFilter, setLocationFilter, - showAlbumPicker, setShowAlbumPicker, albums, albumsLoading, albumLinks, syncing, - lightboxId, setLightboxId, lightboxUserId, setLightboxUserId, lightboxInfo, setLightboxInfo, - lightboxInfoLoading, setLightboxInfoLoading, lightboxOriginalSrc, setLightboxOriginalSrc, - showMobileInfo, setShowMobileInfo, isMobile, pickerDateFilter, setPickerDateFilter, - startDate, endDate, - buildProviderAssetUrl, buildProviderAssetUrlFromAsset, - openAlbumPicker, linkAlbum, unlinkAlbum, syncAlbum, openPicker, loadPickerPhotos, - togglePickerSelect, confirmSelection, executeAddPhotos, removePhoto, toggleSharing, makePickerKey, - ownPhotos, othersPhotos, allVisibleRaw, locations, allVisible, font, - } -} - -type MemoriesState = ReturnType - -// ── Shared bits ─────────────────────────────────────────────────────────── - -function ProviderTabs({ availableProviders, selectedProvider, setSelectedProvider }: MemoriesState) { - if (availableProviders.length < 2) return null - return ( -
- {availableProviders.map(provider => ( - - ))} -
- ) -} - -function ConfirmSharePopup(S: MemoriesState) { - const { setShowConfirmShare, t, selectedIds, executeAddPhotos } = S - return ( -
setShowConfirmShare(false)} - style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}> -
e.stopPropagation()} - style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}> - -

- {t('memories.confirmShareTitle')} -

-

- {t('memories.confirmShareHint', { count: selectedIds.size })} -

-
- - -
-
-
- ) -} - -// ── Early-state views ─────────────────────────────────────────────────────── - -function MemoriesLoading({ font }: MemoriesState) { - return ( -
-
-
- ) -} - -function MemoriesNotConnected({ font, t, enabledProviders }: MemoriesState) { - return ( -
- -

- {t('memories.notConnected', { provider_name: enabledProviders.length === 1 ? enabledProviders[0]?.name : 'Photo provider' })} -

-

- {enabledProviders.length === 1 ? t('memories.notConnectedHint', { provider_name: enabledProviders[0]?.name }) : t('memories.notConnectedMultipleHint', { provider_names: enabledProviders.map(p => p.name).join(', ') })} -

-
- ) -} - -function AlbumPickerView(S: MemoriesState) { - const { font, t, availableProviders, selectedProvider, setShowAlbumPicker, albumsLoading, albums, albumLinks, linkAlbum } = S - const linkedIds = new Set(albumLinks.map(l => l.album_id)) - return ( -
-
-
-

- {availableProviders.length > 1 ? t('memories.selectAlbumMultiple') : t('memories.selectAlbum', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })} -

- -
- -
-
- {albumsLoading ? ( -
-
-
- ) : albums.length === 0 ? ( -

- {t('memories.noAlbums')} -

- ) : ( -
- {albums.map(album => { - const isLinked = linkedIds.has(album.id) - return ( - - ) - })} -
- )} -
-
- ) -} - -function PhotoPickerView(S: MemoriesState) { - const { - font, t, availableProviders, selectedProvider, tripPhotos, currentUser, makePickerKey, - setShowPicker, confirmSelection, selectedIds, startDate, endDate, pickerDateFilter, setPickerDateFilter, - loadPickerPhotos, pickerLoading, pickerPhotos, buildProviderAssetUrlFromAsset, togglePickerSelect, showConfirmShare, - } = S - const alreadyAdded = new Set( - tripPhotos - .filter(p => p.user_id === currentUser?.id) - .map(p => makePickerKey(p.provider, p.asset_id)) - ) - - return ( - <> -
- {/* Picker header */} -
-
-

- {availableProviders.length > 1 ? t('memories.selectPhotosMultiple') : t('memories.selectPhotos', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })} -

-
- - -
-
-
- -
- {/* Filter tabs */} -
- {startDate && endDate && ( - - )} - -
- {selectedIds.size > 0 && ( -

- {selectedIds.size} {t('memories.selected')} -

- )} -
- - {/* Picker grid */} -
- {pickerLoading ? ( -
-
-
- ) : pickerPhotos.length === 0 ? ( -
- -

{t('memories.noPhotos')}

- { - pickerDateFilter && ( -

- {t('memories.noPhotosHint', { provider_name: availableProviders.find(p => p.id === selectedProvider)?.name || 'Photo provider' })} -

- ) - } -
- ) : (() => { - // Group photos by month - const byMonth: Record = {} - for (const asset of pickerPhotos) { - const d = asset.takenAt ? new Date(asset.takenAt) : null - const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown' - if (!byMonth[key]) byMonth[key] = [] - byMonth[key].push(asset) - } - const sortedMonths = Object.keys(byMonth).sort().reverse() - - return sortedMonths.map(month => ( -
-
- {month !== 'unknown' - ? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) - : '—'} -
-
- {byMonth[month].map(asset => { - const pickerKey = makePickerKey(asset.provider, asset.id) - const isSelected = selectedIds.has(pickerKey) - const isAlready = alreadyAdded.has(pickerKey) - return ( -
!isAlready && togglePickerSelect(pickerKey)} - style={{ - position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden', - cursor: isAlready ? 'default' : 'pointer', - opacity: isAlready ? 0.3 : 1, - outline: isSelected ? '3px solid var(--text-primary)' : 'none', - outlineOffset: -3, - }}> - - {isSelected && ( -
- -
- )} - {isAlready && ( -
- {t('memories.alreadyAdded')} -
- )} -
- ) - })} -
-
- )) - })()} -
-
- - {/* Confirm share popup (inside picker) */} - {showConfirmShare && } - - ) -} - -// ── Lightbox ──────────────────────────────────────────────────────────────── - -function MemoriesLightbox(S: MemoriesState) { - const { - lightboxId, setLightboxId, setLightboxUserId, lightboxInfo, setLightboxInfo, lightboxInfoLoading, - setLightboxInfoLoading, lightboxOriginalSrc, setLightboxOriginalSrc, showMobileInfo, setShowMobileInfo, - isMobile, allVisible, buildProviderAssetUrl, - } = S - const closeLightbox = () => { - if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) - setLightboxOriginalSrc('') - setLightboxId(null) - setLightboxUserId(null) - setShowMobileInfo(false) - } - - const currentIdx = allVisible.findIndex(p => p.photo_id === lightboxId) - const hasPrev = currentIdx > 0 - const hasNext = currentIdx < allVisible.length - 1 - const navigateTo = (idx: number) => { - const photo = allVisible[idx] - if (!photo) return - if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) - setLightboxOriginalSrc('') - setLightboxId(photo.photo_id) - setLightboxUserId(photo.user_id) - setLightboxInfo(null) - fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) - setLightboxInfoLoading(true) - apiClient.get(buildProviderAssetUrl(photo, 'info')) - .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) - } - - const exifContent = lightboxInfo ? ( - <> - {lightboxInfo.takenAt && ( -
-
Date
-
{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}
-
{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
-
- )} - {(lightboxInfo.city || lightboxInfo.country) && ( -
-
- Location -
-
- {[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')} -
-
- )} - {lightboxInfo.camera && ( -
-
Camera
-
{lightboxInfo.camera}
- {lightboxInfo.lens &&
{lightboxInfo.lens}
} -
- )} - {(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && ( -
- {lightboxInfo.focalLength && ( -
-
Focal
-
{lightboxInfo.focalLength}
-
- )} - {lightboxInfo.aperture && ( -
-
Aperture
-
{lightboxInfo.aperture}
-
- )} - {lightboxInfo.shutter && ( -
-
Shutter
-
{lightboxInfo.shutter}
-
- )} - {lightboxInfo.iso && ( -
-
ISO
-
{lightboxInfo.iso}
-
- )} -
- )} - {(lightboxInfo.width || lightboxInfo.fileName) && ( -
- {lightboxInfo.width && lightboxInfo.height && ( -
{lightboxInfo.width} × {lightboxInfo.height}
- )} - {lightboxInfo.fileSize && ( -
{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB
- )} -
- )} - - ) : null - - return ( -
{ if (e.key === 'ArrowLeft' && hasPrev) navigateTo(currentIdx - 1); if (e.key === 'ArrowRight' && hasNext) navigateTo(currentIdx + 1); if (e.key === 'Escape') closeLightbox() }} - tabIndex={0} ref={el => el?.focus()} - onTouchStart={e => (e.currentTarget as any)._touchX = e.touches[0].clientX} - onTouchEnd={e => { const start = (e.currentTarget as any)._touchX; if (start == null) return; const diff = e.changedTouches[0].clientX - start; if (diff > 60 && hasPrev) navigateTo(currentIdx - 1); else if (diff < -60 && hasNext) navigateTo(currentIdx + 1) }} - style={{ - position: 'absolute', inset: 0, zIndex: 100, outline: 'none', - background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center', - }}> - {/* Close button */} - - - {/* Counter */} - {allVisible.length > 1 && ( -
- {currentIdx + 1} / {allVisible.length} -
- )} - - {/* Prev/Next buttons */} - {hasPrev && ( - - )} - {hasNext && ( - - )} - - {/* Mobile info toggle button */} - {isMobile && (lightboxInfo || lightboxInfoLoading) && ( - - )} - -
{ if (e.target === e.currentTarget) closeLightbox() }} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}> - e.stopPropagation()} - style={{ maxWidth: (!isMobile && lightboxInfo) ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }} - /> - - {/* Desktop info panel — liquid glass */} - {!isMobile && lightboxInfo && ( -
- {exifContent} -
- )} - - {!isMobile && lightboxInfoLoading && ( -
-
-
- )} -
- - {/* Mobile bottom sheet */} - {isMobile && showMobileInfo && lightboxInfo && ( -
e.stopPropagation()} style={{ - position: 'absolute', bottom: 0, left: 0, right: 0, zIndex: 5, - maxHeight: '60vh', overflowY: 'auto', - borderRadius: '16px 16px 0 0', padding: 18, - background: 'rgba(0,0,0,0.85)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', - border: '1px solid rgba(255,255,255,0.12)', borderBottom: 'none', - color: 'white', display: 'flex', flexDirection: 'column', gap: 14, - }}> - {exifContent} -
- )} -
- ) -} - -// ── Main gallery ────────────────────────────────────────────────────────── - -function MainGallery(S: MemoriesState) { - const { - font, connected, allVisible, enabledProviders, t, allVisibleRaw, othersPhotos, openAlbumPicker, openPicker, - albumLinks, currentUser, syncAlbum, syncing, unlinkAlbum, sortAsc, setSortAsc, locations, locationFilter, setLocationFilter, - setLightboxId, setLightboxUserId, setLightboxInfo, lightboxOriginalSrc, setLightboxOriginalSrc, setLightboxInfoLoading, - buildProviderAssetUrl, toggleSharing, removePhoto, showConfirmShare, lightboxId, lightboxUserId, - } = S - return ( -
- - {/* Disconnected banner — shown when photos exist but provider is unreachable */} - {!connected && allVisible.length > 0 && enabledProviders.length > 0 && ( -
- - - {t('memories.providerDisconnectedBanner', { - provider_name: enabledProviders.length === 1 ? enabledProviders[0].name : enabledProviders.map(p => p.name).join(', ') - })} - -
- )} - - {/* Header */} -
-
-
-

- {t('memories.title')} -

-

- {allVisible.length} {t('memories.photosFound')} - {othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`} -

-
- {connected && ( -
- - -
- )} -
- - {/* Linked Albums */} - {albumLinks.length > 0 && ( -
- {albumLinks.map(link => ( -
- - {link.album_name} - {link.username !== currentUser?.username && ({link.username})} - - {link.user_id === currentUser?.id && ( - - )} -
- ))} -
- )} -
- - {/* Filter & Sort bar */} - {allVisibleRaw.length > 0 && ( -
- - {locations.length > 1 && ( - - )} -
- )} - - {/* Gallery */} -
- {allVisible.length === 0 ? ( -
- -

- {t('memories.noPhotos')} -

- -
- ) : ( -
- {allVisible.map(photo => { - const isOwn = photo.user_id === currentUser?.id - return ( -
{ - setLightboxId(photo.photo_id); setLightboxUserId(photo.user_id); setLightboxInfo(null) - if (lightboxOriginalSrc) URL.revokeObjectURL(lightboxOriginalSrc) - setLightboxOriginalSrc('') - fetchImageAsBlob('/api' + buildProviderAssetUrl(photo, 'original')).then(setLightboxOriginalSrc) - setLightboxInfoLoading(true) - apiClient.get(buildProviderAssetUrl(photo, 'info')) - .then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false)) - }}> - - - - {/* Other user's avatar */} - {!isOwn && ( -
-
- {photo.username[0]} -
-
- {photo.username} -
-
- )} - - {/* Own photo actions (hover) */} - {isOwn && ( -
- - -
- )} - - {/* Not shared indicator */} - {isOwn && !photo.shared && ( -
- - {t('memories.private')} -
- )} -
- ) - })} -
- )} -
- - - - {/* Confirm share popup */} - {showConfirmShare && } - - {/* Lightbox */} - {lightboxId && lightboxUserId && } -
- ) -} - -// ── Main Component ────────────────────────────────────────────────────────── - -export default function MemoriesPanel(props: MemoriesPanelProps) { - const S = useMemoriesPanel(props) - if (S.loading) return - if (!S.connected && S.allVisible.length === 0) return - if (S.showAlbumPicker) return - if (S.showPicker) return - return -} diff --git a/client/src/components/shared/CopyButton.tsx b/client/src/components/shared/CopyButton.tsx deleted file mode 100644 index 59f84dd6..00000000 --- a/client/src/components/shared/CopyButton.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React, { useCallback, useState } from 'react' -import { Copy, Check } from 'lucide-react' - -interface CopyButtonProps { - value: string - size?: number - title?: string - className?: string - onCopy?: () => void -} - -// Button that morphs between copy icon and check icon for 1.5s after click. -export function CopyButton({ value, size = 14, title, className, onCopy }: CopyButtonProps): React.ReactElement { - const [copied, setCopied] = useState(false) - - const handleClick = useCallback(async (e: React.MouseEvent) => { - e.stopPropagation() - try { - await navigator.clipboard.writeText(value) - setCopied(true) - onCopy?.() - window.setTimeout(() => setCopied(false), 1500) - } catch { - // noop - } - }, [value, onCopy]) - - return ( - - ) -} - -export default CopyButton diff --git a/client/src/components/shared/LoadingImage.tsx b/client/src/components/shared/LoadingImage.tsx deleted file mode 100644 index e71bdb64..00000000 --- a/client/src/components/shared/LoadingImage.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React, { useState, type ImgHTMLAttributes } from 'react' - -interface LoadingImageProps extends ImgHTMLAttributes { - containerClassName?: string - containerStyle?: React.CSSProperties -} - -// Image with shimmer-placeholder until loaded. Drops the shimmer once native load fires. -export function LoadingImage({ - containerClassName, containerStyle, className, style, onLoad, ...imgProps -}: LoadingImageProps): React.ReactElement { - const [loaded, setLoaded] = useState(false) - return ( -
- {!loaded && ( -
- )} - { setLoaded(true); onLoad?.(e) }} - /> -
- ) -} - -export default LoadingImage diff --git a/client/src/hooks/usePendingMutations.ts b/client/src/hooks/usePendingMutations.ts deleted file mode 100644 index 4d73b9aa..00000000 --- a/client/src/hooks/usePendingMutations.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * usePendingMutations — returns the set of entity IDs that have a pending - * or syncing mutation for a given trip. - * - * Components use this to render a clock/pending indicator on list rows. - * Polls Dexie every 2 s so the indicator clears automatically once synced. - */ -import { useState, useEffect } from 'react' -import { mutationQueue } from '../sync/mutationQueue' - -const POLL_MS = 2_000 - -export function usePendingMutations(tripId: number): Set { - const [pendingIds, setPendingIds] = useState>(new Set()) - - useEffect(() => { - let cancelled = false - - async function refresh() { - const pending = await mutationQueue.pending(tripId) - if (cancelled) return - - const ids = new Set() - for (const m of pending) { - // Extract entity id from the mutation URL (last numeric segment) - const match = m.url.match(/\/(\d+)$/) - if (match) ids.add(Number(match[1])) - // Also include tempId for offline-created items - if (m.tempId !== undefined) ids.add(m.tempId) - } - setPendingIds(ids) - } - - refresh() - const timer = setInterval(refresh, POLL_MS) - return () => { - cancelled = true - clearInterval(timer) - } - }, [tripId]) - - return pendingIds -} diff --git a/client/tests/unit/utils/reorder.test.ts b/client/tests/unit/utils/reorder.test.ts deleted file mode 100644 index 50c7f27b..00000000 --- a/client/tests/unit/utils/reorder.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { swapItems } from '../../../src/utils/reorder'; - -// FE-UTIL-020 onwards - -const items = [ - { id: 10 }, - { id: 20 }, - { id: 30 }, - { id: 40 }, -]; - -describe('swapItems', () => { - it('FE-UTIL-020: swaps item up with its predecessor', () => { - const result = swapItems(items, 1, 'up'); - expect(result).toEqual([20, 10, 30, 40]); - }); - - it('FE-UTIL-021: swaps item down with its successor', () => { - const result = swapItems(items, 1, 'down'); - expect(result).toEqual([10, 30, 20, 40]); - }); - - it('FE-UTIL-022: returns null when moving first item up (out of bounds)', () => { - expect(swapItems(items, 0, 'up')).toBeNull(); - }); - - it('FE-UTIL-023: returns null when moving last item down (out of bounds)', () => { - expect(swapItems(items, items.length - 1, 'down')).toBeNull(); - }); - - it('FE-UTIL-024: swaps first and second items when moving index 1 up', () => { - const result = swapItems(items, 1, 'up'); - expect(result![0]).toBe(20); - expect(result![1]).toBe(10); - }); - - it('FE-UTIL-025: returns an array of IDs (not objects)', () => { - const result = swapItems(items, 0, 'down'); - expect(Array.isArray(result)).toBe(true); - expect(typeof result![0]).toBe('number'); - }); - - it('FE-UTIL-026: does not mutate the original array', () => { - const original = [{ id: 1 }, { id: 2 }, { id: 3 }]; - const snapshot = original.map((o) => o.id); - swapItems(original, 0, 'down'); - expect(original.map((o) => o.id)).toEqual(snapshot); - }); - - it('FE-UTIL-027: returns null for a single-element array moving down', () => { - expect(swapItems([{ id: 5 }], 0, 'down')).toBeNull(); - }); - - it('FE-UTIL-028: returns null for a single-element array moving up', () => { - expect(swapItems([{ id: 5 }], 0, 'up')).toBeNull(); - }); - - it('FE-UTIL-029: swaps last two items when moving second-to-last down', () => { - const result = swapItems(items, items.length - 2, 'down'); - expect(result).toEqual([10, 20, 40, 30]); - }); -}); diff --git a/server/package.json b/server/package.json index 4ea047d7..0c730e65 100644 --- a/server/package.json +++ b/server/package.json @@ -16,7 +16,6 @@ "test:unit": "vitest run tests/unit", "test:integration": "vitest run tests/integration", "test:ws": "vitest run tests/websocket", - "test:parity": "vitest run tests/parity", "test:e2e": "vitest run tests/e2e", "test:coverage": "vitest run --coverage" }, diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts new file mode 100644 index 00000000..9b4145b6 --- /dev/null +++ b/server/src/bootstrap.ts @@ -0,0 +1,45 @@ +import { NestFactory } from '@nestjs/core'; +import { ExpressAdapter } from '@nestjs/platform-express'; +import type { INestApplication } from '@nestjs/common'; +import { AppModule } from './nest/app.module'; +import { applyGlobalMiddleware } from './middleware/globalMiddleware'; +import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes'; + +/** + * Builds the unified TREK NestJS application that serves the ENTIRE surface — the + * former Express app is gone. One builder is shared by the production bootstrap + * (index.ts) and the integration-test harness so the two can never drift. + * + * Composition order is load-bearing. Everything except the SPA index.html fallback + * is registered on the underlying Express instance BEFORE `app.init()`, because + * Nest's router terminates an unmatched request by throwing NotFoundException — it + * does NOT fall through to a route registered after init, so a post-init Express + * route is unreachable. The platform routes are all specific paths (/uploads/*, + * /api/health, /mcp, /.well-known/*, /oauth/{authorize,register,consent}) so they + * match their own requests and `next()` everything else through to the Nest + * controllers registered during init. + * + * 1. applyGlobalMiddleware — helmet/CSP, CORS, HSTS, forced-HTTPS, the global MFA + * policy, request logging + cookie-parser. `bodyParser: false` so Nest does its + * own parsing and the raw /mcp body reaches the MCP handler unparsed. + * 2. applyPlatformUploads — the static + guarded /uploads/* routes. + * 3. applyPlatformTransport — /api/health, the OAuth/MCP SDK + /.well-known + * metadata, the /mcp routes, the /oauth/consent COOP header. + * 4. applyPlatformStatic — the production built-client static assets (so a real + * asset request returns the file before the Nest router 404s it). + * 5. app.init() — registers every migrated /api domain (the Nest controllers). + * + * The SPA index.html fallback (unmatched GET → index.html in production) is the + * SpaFallbackFilter (APP_FILTER in AppModule); the global error envelope is the + * TrekExceptionFilter (also APP_FILTER). + */ +export async function buildApp(): Promise { + const app = await NestFactory.create(AppModule, new ExpressAdapter()); + const instance = app.getHttpAdapter().getInstance(); + applyGlobalMiddleware(instance, { bodyParser: false }); + applyPlatformUploads(instance); + applyPlatformTransport(instance); + applyPlatformStatic(instance); + await app.init(); + return app; +} diff --git a/server/src/index.ts b/server/src/index.ts index 7cc1c66c..2504898e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,14 +3,8 @@ import 'dotenv/config'; import path from 'node:path'; import fs from 'node:fs'; import http from 'node:http'; -import express from 'express'; -import { NestFactory } from '@nestjs/core'; -import { ExpressAdapter } from '@nestjs/platform-express'; import type { INestApplication } from '@nestjs/common'; -import { createApp } from './app'; -import { AppModule } from './nest/app.module'; -import { getNestPrefixes, makeNestPathMatcher } from './nest/strangler'; -import { applyGlobalMiddleware } from './middleware/globalMiddleware'; +import { buildApp } from './bootstrap'; // Create upload and data directories on startup const uploadsDir = path.join(__dirname, '../uploads'); @@ -25,11 +19,6 @@ const tmpDir = path.join(__dirname, '../data/tmp'); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); }); -// Legacy Express app — unchanged. NestJS (its own Express 5 instance) is mounted -// in front of it (strangler pattern): migrated route prefixes are served by Nest, -// everything else falls through to this app via a fallback middleware. -const legacyApp = createApp(); - import * as scheduler from './scheduler'; import { getAppUrl, getMcpSafeUrl } from './services/notifications'; @@ -61,11 +50,7 @@ const onListen = () => { '──────────────────────────────────────', ]; banner.forEach(l => console.log(l)); - sLogInfo( - NEST_PREFIXES.length - ? `NestJS handling prefixes: ${NEST_PREFIXES.join(', ')} (override via NEST_PREFIXES)` - : 'NestJS prefixes: none — all routes served by the legacy Express app', - ); + sLogInfo('NestJS serving all routes (Express decommissioned)'); if (process.env.APP_URL) { let parsedAppUrl: URL | null = null; try { parsedAppUrl = new URL(process.env.APP_URL); } catch { /* invalid */ } @@ -105,34 +90,13 @@ let server: http.Server; let nestApp: INestApplication; // Strangler toggle: prefixes served by Nest (env-overridable, instant rollback). -const NEST_PREFIXES = getNestPrefixes(); -const isNestPath = makeNestPathMatcher(NEST_PREFIXES); - async function bootstrap(): Promise { - // Nest runs on its own Express instance (bodyParser off so request bodies reach - // the legacy app untouched — it has its own parsers; /mcp relies on raw body). - // Nest body parsing is safe here: the dispatcher only forwards migrated - // prefixes to this instance, so the legacy app (and raw-body routes like /mcp) - // is reached separately and never passes through Nest's parser. - nestApp = await NestFactory.create(AppModule, new ExpressAdapter()); - const nestInstance = nestApp.getHttpAdapter().getInstance(); - // Apply the SAME global request pipeline the legacy app uses (helmet/CSP, CORS, - // HSTS, forced-HTTPS, the global MFA policy, request logging + cookie-parser) so a - // migrated Nest route is protected identically to the legacy fallback. Without this - // the dispatcher forwards Nest paths straight to this instance, bypassing all of it. - // Nest does its own body parsing, so bodyParser:false avoids parsing twice. - applyGlobalMiddleware(nestInstance, { bodyParser: false }); - // (TrekExceptionFilter is registered globally via APP_FILTER in AppModule.) - await nestApp.init(); - - // Top-level dispatcher: migrated prefixes -> Nest, everything else -> legacy - // Express (unchanged). Nest never sees non-migrated paths, so its 404 handler - // only applies within migrated prefixes. - const top = express(); - top.use((req, res, next) => (isNestPath(req.path) ? nestInstance(req, res, next) : next())); - top.use(legacyApp); - - server = http.createServer(top); + // The whole surface runs on the single NestJS app now (Express decommissioned): + // global pipeline + /uploads + every /api domain + the platform/transport routes + // (/mcp, /.well-known, OAuth SDK, SPA catch-all). buildApp() owns the composition + // order; it is shared with the integration-test harness so they can't drift. + nestApp = await buildApp(); + server = http.createServer(nestApp.getHttpAdapter().getInstance()); if (HOST) server.listen(PORT, HOST, onListen); else server.listen(PORT, onListen); } @@ -165,5 +129,3 @@ function shutdown(signal: string): void { process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); - -export default legacyApp; diff --git a/server/src/mcp/tools/journey.ts b/server/src/mcp/tools/journey.ts index 90f2e40a..10b985e7 100644 --- a/server/src/mcp/tools/journey.ts +++ b/server/src/mcp/tools/journey.ts @@ -380,6 +380,7 @@ export function registerJourneyTools(server: McpServer, userId: number, scopes: annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ journeyId }) => { + if (!canAccessJourney(journeyId, userId)) return notFound('Journey not found or access denied.'); const shareLink = getJourneyShareLink(journeyId); return ok({ shareLink }); } diff --git a/server/src/nest/addons/addons.controller.ts b/server/src/nest/addons/addons.controller.ts new file mode 100644 index 00000000..b9cf54ff --- /dev/null +++ b/server/src/nest/addons/addons.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AddonsService } from './addons.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; + +/** + * GET /api/addons — the enabled trip add-ons + photo providers feed. + * Byte-identical to the legacy inline handler in server/src/app.ts + * (authenticate-gated, returns { collabFeatures, addons: [...] }). + * + * Distinct from the addon sub-mounts /api/addons/atlas and /api/addons/vacay + * (their own Nest modules); the strangler routes only the EXACT /api/addons here. + */ +@Controller('api/addons') +@UseGuards(JwtAuthGuard) +export class AddonsController { + constructor(private readonly addons: AddonsService) {} + + @Get() + list() { + return this.addons.list(); + } +} diff --git a/server/src/nest/addons/addons.module.ts b/server/src/nest/addons/addons.module.ts new file mode 100644 index 00000000..078c3b52 --- /dev/null +++ b/server/src/nest/addons/addons.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { AddonsController } from './addons.controller'; +import { AddonsService } from './addons.service'; + +/** + * GET /api/addons — enabled add-ons + photo providers (was an inline handler in + * server/src/app.ts). The addon sub-features (atlas, vacay) keep their own + * modules; this only serves the EXACT /api/addons listing. + */ +@Module({ + controllers: [AddonsController], + providers: [AddonsService], +}) +export class AddonsModule {} diff --git a/server/src/nest/addons/addons.service.ts b/server/src/nest/addons/addons.service.ts new file mode 100644 index 00000000..aef063cd --- /dev/null +++ b/server/src/nest/addons/addons.service.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { db } from '../../db/database'; +import type { Addon } from '../../types'; +import { getCollabFeatures } from '../../services/adminService'; +import { getPhotoProviderConfig } from '../../services/memories/helpersService'; + +/** + * Thin wrapper around the enabled-addons + photo-provider read that the legacy + * inline `GET /api/addons` handler performed (server/src/app.ts). The SQL, + * ordering, boolean coercions and the merged photo-provider entries are + * reproduced 1:1 so the body is byte-identical for the client. + */ +@Injectable() +export class AddonsService { + list() { + const addons = db + .prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order') + .all() as Pick[]; + const providers = db + .prepare( + `SELECT id, name, icon, enabled, sort_order + FROM photo_providers + WHERE enabled = 1 + ORDER BY sort_order, id`, + ) + .all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; + const fields = db + .prepare( + `SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order + FROM photo_provider_fields + ORDER BY sort_order, id`, + ) + .all() as Array<{ + provider_id: string; + field_key: string; + label: string; + input_type: string; + placeholder?: string | null; + hint?: string | null; + required: number; + secret: number; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; + }>; + + const fieldsByProvider = new Map(); + for (const field of fields) { + const arr = fieldsByProvider.get(field.provider_id) || []; + arr.push(field); + fieldsByProvider.set(field.provider_id, arr); + } + + return { + collabFeatures: getCollabFeatures(), + addons: [ + ...addons.map((a) => ({ ...a, enabled: !!a.enabled })), + ...providers.map((p) => ({ + id: p.id, + name: p.name, + type: 'photo_provider', + icon: p.icon, + enabled: !!p.enabled, + config: getPhotoProviderConfig(p.id), + fields: (fieldsByProvider.get(p.id) || []).map((f) => ({ + key: f.field_key, + label: f.label, + input_type: f.input_type, + placeholder: f.placeholder || '', + hint: f.hint || null, + required: !!f.required, + secret: !!f.secret, + settings_key: f.settings_key || null, + payload_key: f.payload_key || null, + sort_order: f.sort_order, + })), + })), + ], + }; + } +} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 0c8de243..45922464 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -24,6 +24,7 @@ import { TodoModule } from './todo/todo.module'; import { CollabModule } from './collab/collab.module'; import { FilesModule } from './files/files.module'; import { PhotosModule } from './photos/photos.module'; +import { MemoriesModule } from './memories/memories.module'; import { JourneyModule } from './journey/journey.module'; import { ShareModule } from './share/share.module'; import { SettingsModule } from './settings/settings.module'; @@ -32,7 +33,9 @@ import { AuthModule } from './auth/auth.module'; import { OidcModule } from './oidc/oidc.module'; import { OauthModule } from './oauth/oauth.module'; import { AdminModule } from './admin/admin.module'; +import { AddonsModule } from './addons/addons.module'; import { TrekExceptionFilter } from './common/trek-exception.filter'; +import { SpaFallbackFilter } from './platform/spa-fallback.filter'; import { IdempotencyInterceptor } from './common/idempotency.interceptor'; /** @@ -40,13 +43,17 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor'; * (weather, notifications, ...) get registered here as they are migrated. */ @Module({ - imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule], + imports: [DatabaseModule, WeatherModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, JourneyModule, ShareModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule], controllers: [HealthController], providers: [ HealthService, // Global error-envelope normaliser (DI-registered so it also catches // framework-level exceptions like the not-found handler). { provide: APP_FILTER, useClass: TrekExceptionFilter }, + // SPA fallback: serves index.html for unmatched GETs in production (the Nest + // equivalent of the legacy Express app.get('*') catch-all). @Catch(NotFoundException) + // is more specific than TrekExceptionFilter, so Nest routes 404s here. + { provide: APP_FILTER, useClass: SpaFallbackFilter }, // Replays the X-Idempotency-Key the client sends on every write, matching // the legacy applyIdempotency middleware so retried mutations don't double-apply. { provide: APP_INTERCEPTOR, useClass: IdempotencyInterceptor }, diff --git a/server/src/nest/collab/collab.controller.ts b/server/src/nest/collab/collab.controller.ts index e1a80ff2..b51018b1 100644 --- a/server/src/nest/collab/collab.controller.ts +++ b/server/src/nest/collab/collab.controller.ts @@ -33,6 +33,7 @@ const NOTE_UPLOAD = { filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), }), limits: { fileSize: MAX_NOTE_FILE_SIZE }, + defParamCharset: 'utf8', // parity with legacy routes/collab.ts — preserve non-ASCII original filenames fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { const ext = path.extname(file.originalname).toLowerCase(); if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) { diff --git a/server/src/nest/common/trek-exception.filter.ts b/server/src/nest/common/trek-exception.filter.ts index 193ddf79..6b891078 100644 --- a/server/src/nest/common/trek-exception.filter.ts +++ b/server/src/nest/common/trek-exception.filter.ts @@ -1,42 +1,69 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common'; import type { Response } from 'express'; +import { MulterError } from 'multer'; /** * Normalises every Nest exception to TREK's legacy error envelope so migrated - * routes are byte-identical for the client: - * - 4xx -> { error: } (5xx -> { error: 'Internal server error' }) - * - exceptions already throwing { error, code? } (e.g. the auth guards) pass through - * This replaces Nest's default { statusCode, message, error } body, which the - * TREK client does not expect. + * routes are byte-identical for the client. This mirrors the legacy global + * Express error handler (server/src/app.ts) exactly: + * - multer errors -> 413 (LIMIT_FILE_SIZE) / 400, body { error: } + * - { error, code? } bodies -> passed through unchanged (auth guards, ZodValidationPipe) + * - other HttpExceptions -> { error: } at the same status + * - plain errors w/ statusCode/status -> that status, { error: } for 4xx + * - everything else -> 500 { error: 'Internal server error' } + * + * Without the multer + statusCode handling, file-upload rejections (multer's + * LIMIT_FILE_SIZE and the fileFilter errors that carry `statusCode = 400`) would + * collapse to Nest's `{ statusCode, message, error }` 413 body or a 500, diverging + * from the legacy `{ error: 'File too large' }` (413) and `{ error: '' }` (400). */ @Catch() export class TrekExceptionFilter implements ExceptionFilter { catch(exception: unknown, host: ArgumentsHost): void { const res = host.switchToHttp().getResponse(); + // 1. Raw multer errors that slipped past @nestjs/platform-express's + // transformException (it leaves codes it does not recognise untouched). + // Legacy: LIMIT_FILE_SIZE -> 413, everything else -> 400, body { error: message }. + if (exception instanceof MulterError) { + const status = exception.code === 'LIMIT_FILE_SIZE' ? 413 : 400; + res.status(status).json({ error: exception.message }); + return; + } + if (exception instanceof HttpException) { const status = exception.getStatus(); const body = exception.getResponse(); - // Already in TREK shape (e.g. guards throw { error, code }): pass through. - if (body && typeof body === 'object' && 'error' in (body as Record)) { - res.status(status).json(body); + if (body && typeof body === 'object') { + const obj = body as Record; + // TREK-native shape ({ error } / { error, code } from guards + the Zod + // pipe): pass through verbatim. Nest's own exceptions instead carry the + // { statusCode, message, error } trio (incl. transformException's + // PayloadTooLargeException for LIMIT_FILE_SIZE) and must be normalised. + if ('error' in obj && !('statusCode' in obj) && !('message' in obj)) { + res.status(status).json(obj); + return; + } + const raw = obj.message ?? obj.error; + const message = + status < 500 ? (Array.isArray(raw) ? raw.join(', ') : String(raw ?? 'Error')) : 'Internal server error'; + res.status(status).json({ error: message }); return; } - const raw = typeof body === 'string' ? body : (body as { message?: unknown })?.message; - const message = - status < 500 - ? Array.isArray(raw) - ? raw.join(', ') - : String(raw ?? 'Error') - : 'Internal server error'; + const message = status < 500 ? String(body ?? 'Error') : 'Internal server error'; res.status(status).json({ error: message }); return; } - // Unknown/unhandled error — mirror the legacy 500 behaviour. - console.error('Unhandled error:', exception); - res.status(500).json({ error: 'Internal server error' }); + // 2. Plain errors carrying an explicit status (the fileFilter rejections set + // `statusCode = 400`; transformException returns them unchanged). Legacy: + // status = err.statusCode || err.status || 500; 4xx exposes err.message. + const err = exception as { statusCode?: number; status?: number; message?: unknown } | null; + const status = (err && (err.statusCode || err.status)) || 500; + if (status >= 500) console.error('Unhandled error:', exception); + const message = status < 500 ? String(err?.message ?? 'Error') : 'Internal server error'; + res.status(status).json({ error: message }); } } diff --git a/server/src/nest/files/files-download.controller.ts b/server/src/nest/files/files-download.controller.ts index aeed981b..2fab2d18 100644 --- a/server/src/nest/files/files-download.controller.ts +++ b/server/src/nest/files/files-download.controller.ts @@ -48,6 +48,11 @@ export class FilesDownloadController { res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`); } - res.sendFile(resolved); + // Serve with an explicit { root } + basename rather than an absolute path: + // under the Nest ExpressAdapter, res.sendFile(absolutePath) resolves the + // file relative to the (rewritten) req.url and fails with a spurious + // "Not Found", whereas the root-relative form streams correctly. The + // resolveFilePath guard above already pins this to the uploads dir. + res.sendFile(path.basename(resolved), { root: path.dirname(resolved) }); } } diff --git a/server/src/nest/files/files.controller.ts b/server/src/nest/files/files.controller.ts index 9ab22a48..cb290c66 100644 --- a/server/src/nest/files/files.controller.ts +++ b/server/src/nest/files/files.controller.ts @@ -33,6 +33,7 @@ const UPLOAD = { filename: (_req, file, cb) => cb(null, `${uuidv4()}${path.extname(file.originalname)}`), }), limits: { fileSize: MAX_FILE_SIZE }, + defParamCharset: 'utf8', // parity with legacy routes/files.ts — preserve non-ASCII original filenames fileFilter: (_req: unknown, file: Express.Multer.File, cb: (err: Error | null, accept: boolean) => void) => { const ext = path.extname(file.originalname).toLowerCase(); const reject = () => { diff --git a/server/src/nest/memories/immich.controller.ts b/server/src/nest/memories/immich.controller.ts new file mode 100644 index 00000000..d046ce87 --- /dev/null +++ b/server/src/nest/memories/immich.controller.ts @@ -0,0 +1,193 @@ +import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Req, Res, UseGuards } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import type { User } from '../../types'; +import { MemoriesService } from './memories.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { getClientIp } from '../../services/auditLog'; + +/** + * /api/integrations/memories/immich — Immich connection, browse/search, asset + * proxy and album linking. + * + * Byte-identical to the legacy Express router (server/src/routes/memories/immich.ts): + * `/status` and `/test` answer 200 even on connection failure (the service shapes + * `{ connected: false, ... }`); `/settings` PUT validates with a 400; the asset + * routes do the 400 invalid-id guard then the canAccessUserPhoto 403 ('Forbidden') + * before streaming or returning info; the album sync answers 200 then broadcasts. + * The legacy `canAccessTrip` import there is dead code — intentionally not ported. + */ +@Controller('api/integrations/memories/immich') +@UseGuards(JwtAuthGuard) +export class ImmichMemoriesController { + constructor(private readonly memories: MemoriesService) {} + + @Get('settings') + getSettings(@CurrentUser() user: User) { + return this.memories.immichGetConnectionSettings(user.id); + } + + @Put('settings') + async putSettings( + @CurrentUser() user: User, + @Body() body: { immich_url?: string; immich_api_key?: string; auto_upload?: unknown }, + @Req() req: Request, + @Res() res: Response, + ): Promise { + const { immich_url, immich_api_key, auto_upload } = body; + const result = await this.memories.immichSaveSettings(user.id, immich_url, immich_api_key, getClientIp(req)); + if (!result.success) { + res.status(400).json({ error: result.error }); + return; + } + if (typeof auto_upload === 'boolean') { + this.memories.immichSetAutoUpload(user.id, auto_upload); + } + if (result.warning) { + res.json({ success: true, warning: result.warning }); + return; + } + res.json({ success: true }); + } + + @Get('status') + async getStatus(@CurrentUser() user: User) { + return this.memories.immichGetConnectionStatus(user.id); + } + + @Post('test') + @HttpCode(200) + async test(@Body() body: { immich_url?: string; immich_api_key?: string }) { + const { immich_url, immich_api_key } = body; + if (!immich_url || !immich_api_key) { + return { connected: false, error: 'URL and API key required' }; + } + return this.memories.immichTestConnection(immich_url, immich_api_key); + } + + @Get('browse') + async browse(@CurrentUser() user: User, @Res() res: Response): Promise { + const result = await this.memories.immichBrowseTimeline(user.id); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json({ buckets: result.buckets }); + } + + @Post('search') + @HttpCode(200) + async search(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise { + const { from, to, size, page } = body as { from?: string; to?: string; size?: unknown; page?: unknown }; + const pageNum = Math.max(1, Number(page) || 1); + const pageSize = Math.min(Number(size) || 50, 200); + const result = await this.memories.immichSearchPhotos(user.id, from, to, pageNum, pageSize); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json({ assets: result.assets || [], hasMore: !!result.hasMore }); + } + + @Get('assets/:tripId/:assetId/:ownerId/info') + async assetInfo( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('assetId') assetId: string, + @Param('ownerId') ownerId: string, + @Res() res: Response, + ): Promise { + if (!this.memories.immichIsValidAssetId(assetId)) { + res.status(400).json({ error: 'Invalid asset ID' }); + return; + } + if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + const result = await this.memories.immichGetAssetInfo(user.id, assetId, Number(ownerId)); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json(result.data); + } + + @Get('assets/:tripId/:assetId/:ownerId/thumbnail') + async assetThumbnail( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('assetId') assetId: string, + @Param('ownerId') ownerId: string, + @Res() res: Response, + ): Promise { + if (!this.memories.immichIsValidAssetId(assetId)) { + res.status(400).json({ error: 'Invalid asset ID' }); + return; + } + if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + await this.memories.immichStreamAsset(res, user.id, assetId, 'thumbnail', Number(ownerId)); + } + + @Get('assets/:tripId/:assetId/:ownerId/original') + async assetOriginal( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('assetId') assetId: string, + @Param('ownerId') ownerId: string, + @Res() res: Response, + ): Promise { + if (!this.memories.immichIsValidAssetId(assetId)) { + res.status(400).json({ error: 'Invalid asset ID' }); + return; + } + if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, assetId, 'immich')) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + await this.memories.immichStreamAsset(res, user.id, assetId, 'original', Number(ownerId)); + } + + @Get('albums') + async albums(@CurrentUser() user: User, @Res() res: Response): Promise { + const result = await this.memories.immichListAlbums(user.id); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json({ albums: result.albums }); + } + + @Get('albums/:albumId/photos') + async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Res() res: Response): Promise { + const result = await this.memories.immichGetAlbumPhotos(user.id, albumId); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json({ assets: result.assets }); + } + + @Post('trips/:tripId/album-links/:linkId/sync') + @HttpCode(200) + async sync( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('linkId') linkId: string, + @Headers('x-socket-id') sid: string, + @Res() res: Response, + ): Promise { + const result = await this.memories.immichSyncAlbumAssets(tripId, linkId, user.id, sid); + if (result.error) { + res.status(result.status!).json({ error: result.error }); + return; + } + res.json({ success: true, added: result.added, total: result.total }); + if (result.added! > 0) { + this.memories.broadcast(tripId, 'memories:updated', { userId: user.id }, sid); + } + } +} diff --git a/server/src/nest/memories/memories.module.ts b/server/src/nest/memories/memories.module.ts new file mode 100644 index 00000000..82a4640b --- /dev/null +++ b/server/src/nest/memories/memories.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { MemoriesService } from './memories.service'; +import { UnifiedMemoriesController } from './unified.controller'; +import { ImmichMemoriesController } from './immich.controller'; +import { SynologyMemoriesController } from './synology.controller'; + +/** + * Memories (photo-providers) domain — mounted at /api/integrations/memories. + * + * Ports the legacy Express router (routes/memories/unified.ts, which composes + * immich.ts + synology.ts) to Nest, reusing services/memories/* unchanged. No + * module-level addon gate — enablement is per-provider-row inside the services, + * exactly as the legacy mount had it. + */ +@Module({ + controllers: [UnifiedMemoriesController, ImmichMemoriesController, SynologyMemoriesController], + providers: [MemoriesService], +}) +export class MemoriesModule {} diff --git a/server/src/nest/memories/memories.service.ts b/server/src/nest/memories/memories.service.ts new file mode 100644 index 00000000..e96faf64 --- /dev/null +++ b/server/src/nest/memories/memories.service.ts @@ -0,0 +1,183 @@ +import { Injectable } from '@nestjs/common'; +import type { Response } from 'express'; +import { + listTripPhotos, + listTripAlbumLinks, + createTripAlbumLink, + removeAlbumLink, + addTripPhotos, + removeTripPhoto, + setTripPhotoSharing, +} from '../../services/memories/unifiedService'; +import { + getConnectionSettings, + saveImmichSettings, + setImmichAutoUpload, + testConnection, + getConnectionStatus, + browseTimeline, + searchPhotos, + streamImmichAsset, + listAlbums, + getAlbumPhotos, + syncAlbumAssets, + getAssetInfo, + isValidAssetId, +} from '../../services/memories/immichService'; +import { + getSynologySettings, + updateSynologySettings, + getSynologyStatus, + testSynologyConnection, + listSynologyAlbums, + getSynologyAlbumPhotos, + syncSynologyAlbumLink, + searchSynologyPhotos, + getSynologyAssetInfo, + streamSynologyAsset, +} from '../../services/memories/synologyService'; +import { canAccessUserPhoto } from '../../services/memories/helpersService'; +import type { Selection } from '../../services/memories/helpersService'; +import { broadcast } from '../../websocket'; + +/** + * Thin Nest wrapper around the existing memories (photo-providers) services. + * Every method delegates to the legacy `services/memories/*` code unchanged so + * the provider logic, the per-provider access checks and the streaming helpers + * behave byte-identically to the legacy Express routers. No new business logic + * lives here. + */ +@Injectable() +export class MemoriesService { + // ── Access check (reused by both provider asset routes) ────────────────── + canAccessUserPhoto(requestingUserId: number, ownerUserId: number, tripId: string, assetId: string, provider: string): boolean { + return canAccessUserPhoto(requestingUserId, ownerUserId, tripId, assetId, provider); + } + + broadcast(tripId: string, event: string, payload: Record, socketId?: string): void { + broadcast(tripId, event, payload, socketId); + } + + // ── Unified ────────────────────────────────────────────────────────────── + listTripPhotos(tripId: string, userId: number) { + return listTripPhotos(tripId, userId); + } + + addTripPhotos(tripId: string, userId: number, shared: boolean, selections: Selection[], sid: string) { + return addTripPhotos(tripId, userId, shared, selections, sid); + } + + setTripPhotoSharing(tripId: string, userId: number, photoId: number, shared: boolean) { + return setTripPhotoSharing(tripId, userId, photoId, shared); + } + + removeTripPhoto(tripId: string, userId: number, photoId: number) { + return removeTripPhoto(tripId, userId, photoId); + } + + listTripAlbumLinks(tripId: string, userId: number) { + return listTripAlbumLinks(tripId, userId); + } + + createTripAlbumLink(tripId: string, userId: number, provider: unknown, albumId: unknown, albumName: unknown, passphrase?: string) { + return createTripAlbumLink(tripId, userId, provider, albumId, albumName, passphrase); + } + + removeAlbumLink(tripId: string, linkId: string, userId: number) { + return removeAlbumLink(tripId, linkId, userId); + } + + // ── Immich ───────────────────────────────────────────────────────────────── + immichGetConnectionSettings(userId: number) { + return getConnectionSettings(userId); + } + + immichSaveSettings(userId: number, immichUrl: string | undefined, immichApiKey: string | undefined, clientIp: string | null) { + return saveImmichSettings(userId, immichUrl, immichApiKey, clientIp); + } + + immichSetAutoUpload(userId: number, enabled: boolean): void { + setImmichAutoUpload(userId, enabled); + } + + immichGetConnectionStatus(userId: number) { + return getConnectionStatus(userId); + } + + immichTestConnection(immichUrl: string, immichApiKey: string) { + return testConnection(immichUrl, immichApiKey); + } + + immichBrowseTimeline(userId: number) { + return browseTimeline(userId); + } + + immichSearchPhotos(userId: number, from: string | undefined, to: string | undefined, page: number, size: number) { + return searchPhotos(userId, from, to, page, size); + } + + immichIsValidAssetId(assetId: string): boolean { + return isValidAssetId(assetId); + } + + immichGetAssetInfo(userId: number, assetId: string, ownerId: number) { + return getAssetInfo(userId, assetId, ownerId); + } + + immichStreamAsset(res: Response, userId: number, assetId: string, kind: 'thumbnail' | 'original', ownerId: number) { + return streamImmichAsset(res, userId, assetId, kind, ownerId); + } + + immichListAlbums(userId: number) { + return listAlbums(userId); + } + + immichGetAlbumPhotos(userId: number, albumId: string) { + return getAlbumPhotos(userId, albumId); + } + + immichSyncAlbumAssets(tripId: string, linkId: string, userId: number, sid: string) { + return syncAlbumAssets(tripId, linkId, userId, sid); + } + + // ── Synology ──────────────────────────────────────────────────────────────── + synologyGetSettings(userId: number) { + return getSynologySettings(userId); + } + + synologyUpdateSettings(userId: number, url: string, username: string, password: string, skipSsl: boolean) { + return updateSynologySettings(userId, url, username, password, skipSsl); + } + + synologyGetStatus(userId: number) { + return getSynologyStatus(userId); + } + + synologyTestConnection(userId: number, url: string, username: string, password: string, otp: string, skipSsl: boolean) { + return testSynologyConnection(userId, url, username, password, otp, skipSsl); + } + + synologyListAlbums(userId: number) { + return listSynologyAlbums(userId); + } + + synologyGetAlbumPhotos(userId: number, albumId: string, passphrase?: string) { + return getSynologyAlbumPhotos(userId, albumId, passphrase); + } + + synologySyncAlbumLink(userId: number, tripId: string, linkId: string, sid: string) { + return syncSynologyAlbumLink(userId, tripId, linkId, sid); + } + + synologySearchPhotos(userId: number, from: string | undefined, to: string | undefined, offset: number, limit: number) { + return searchSynologyPhotos(userId, from, to, offset, limit); + } + + synologyGetAssetInfo(userId: number, photoId: string, ownerId: number, passphrase?: string) { + return getSynologyAssetInfo(userId, photoId, ownerId, passphrase); + } + + synologyStreamAsset(res: Response, userId: number, ownerId: number, photoId: string, kind: 'thumbnail' | 'original', size: string, passphrase?: string) { + return streamSynologyAsset(res, userId, ownerId, photoId, kind, size, passphrase); + } +} diff --git a/server/src/nest/memories/synology.controller.ts b/server/src/nest/memories/synology.controller.ts new file mode 100644 index 00000000..88db6133 --- /dev/null +++ b/server/src/nest/memories/synology.controller.ts @@ -0,0 +1,170 @@ +import { Body, Controller, Get, Headers, HttpCode, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; +import type { User } from '../../types'; +import type { ServiceResult } from '../../services/memories/helpersService'; +import { fail, success } from '../../services/memories/helpersService'; +import { MemoriesService } from './memories.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; + +function _parseStringBodyField(value: unknown): string { + return String(value ?? '').trim(); +} + +function _parseNumberBodyField(value: unknown, fallback: number): number { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : fallback; +} + +/** + * /api/integrations/memories/synologyphotos — Synology Photos connection, + * search, albums and asset proxy. + * + * Byte-identical to the legacy Express router (server/src/routes/memories/synology.ts): + * every response goes through the service `ServiceResult` envelope (success → + * `res.json(data)` at 200, error → status + `{ error }`); `/status` and `/test` + * always answer 200 (the service shapes `{ connected: false, error }` on + * failure); the asset routes use the distinct 403 string "You don't have access + * to this photo"; `/info` is declared before the catch-all `/:kind` so the + * literal route wins as Express ordered it; lenient hand-rolled coercion is kept. + */ +@Controller('api/integrations/memories/synologyphotos') +@UseGuards(JwtAuthGuard) +export class SynologyMemoriesController { + constructor(private readonly memories: MemoriesService) {} + + private handle(res: Response, result: ServiceResult): void { + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + } else { + res.json(result.data); + } + } + + @Get('settings') + async getSettings(@CurrentUser() user: User, @Res() res: Response): Promise { + this.handle(res, await this.memories.synologyGetSettings(user.id)); + } + + @Put('settings') + async putSettings(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise { + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); + const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; + + if (!synology_url || !synology_username) { + this.handle(res, fail('URL and username are required', 400)); + } else { + this.handle(res, await this.memories.synologyUpdateSettings(user.id, synology_url, synology_username, synology_password, synology_skip_ssl)); + } + } + + @Get('status') + async getStatus(@CurrentUser() user: User, @Res() res: Response): Promise { + this.handle(res, await this.memories.synologyGetStatus(user.id)); + } + + @Post('test') + @HttpCode(200) + async test(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise { + const synology_url = _parseStringBodyField(body.synology_url); + const synology_username = _parseStringBodyField(body.synology_username); + const synology_password = _parseStringBodyField(body.synology_password); + const synology_otp = _parseStringBodyField(body.synology_otp); + const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; + + if (!synology_url || !synology_username || !synology_password) { + const missingFields: string[] = []; + if (!synology_url) missingFields.push('URL'); + if (!synology_username) missingFields.push('Username'); + if (!synology_password) missingFields.push('Password'); + this.handle(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` })); + } else { + this.handle(res, await this.memories.synologyTestConnection(user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl)); + } + } + + @Get('albums') + async albums(@CurrentUser() user: User, @Res() res: Response): Promise { + this.handle(res, await this.memories.synologyListAlbums(user.id)); + } + + @Get('albums/:albumId/photos') + async albumPhotos(@CurrentUser() user: User, @Param('albumId') albumId: string, @Query('passphrase') passphraseRaw: string | undefined, @Res() res: Response): Promise { + const passphrase = passphraseRaw ? String(passphraseRaw) : undefined; + this.handle(res, await this.memories.synologyGetAlbumPhotos(user.id, albumId, passphrase)); + } + + @Post('trips/:tripId/album-links/:linkId/sync') + @HttpCode(200) + async sync( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('linkId') linkId: string, + @Headers('x-socket-id') sid: string, + @Res() res: Response, + ): Promise { + this.handle(res, await this.memories.synologySyncAlbumLink(user.id, tripId, linkId, sid)); + } + + @Post('search') + @HttpCode(200) + async search(@CurrentUser() user: User, @Body() body: Record, @Res() res: Response): Promise { + const from = _parseStringBodyField(body.from); + const to = _parseStringBodyField(body.to); + let offset = _parseNumberBodyField(body.offset, 0); + const page = _parseNumberBodyField(body.page, 1) - 1; + let limit = _parseNumberBodyField(body.limit, 100); + const size = _parseNumberBodyField(body.size, 0); + if (size > 0) limit = size; + if (page > 0) offset = page * limit; + + this.handle(res, await this.memories.synologySearchPhotos(user.id, from || undefined, to || undefined, offset, limit)); + } + + @Get('assets/:tripId/:photoId/:ownerId/info') + async assetInfo( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('photoId') photoId: string, + @Param('ownerId') ownerId: string, + @Query('passphrase') passphraseRaw: string | undefined, + @Res() res: Response, + ): Promise { + const passphrase = passphraseRaw ? String(passphraseRaw) : undefined; + if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + this.handle(res, fail("You don't have access to this photo", 403)); + } else { + this.handle(res, await this.memories.synologyGetAssetInfo(user.id, photoId, Number(ownerId), passphrase)); + } + } + + @Get('assets/:tripId/:photoId/:ownerId/:kind') + async asset( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('photoId') photoId: string, + @Param('ownerId') ownerId: string, + @Param('kind') kind: string, + @Query('size') sizeRaw: string | undefined, + @Query('passphrase') passphraseRaw: string | undefined, + @Res() res: Response, + ): Promise { + const VALID_SIZES = ['sm', 'm', 'xl'] as const; + const rawSize = String(sizeRaw ?? 'sm'); + const size = (VALID_SIZES as readonly string[]).includes(rawSize) ? rawSize : 'sm'; + const passphrase = passphraseRaw ? String(passphraseRaw) : undefined; + + if (kind !== 'thumbnail' && kind !== 'original') { + this.handle(res, fail('Invalid asset kind', 400)); + return; + } + + if (!this.memories.canAccessUserPhoto(user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { + this.handle(res, fail("You don't have access to this photo", 403)); + } else { + await this.memories.synologyStreamAsset(res, user.id, Number(ownerId), photoId, kind, String(size), passphrase); + } + } +} diff --git a/server/src/nest/memories/unified.controller.ts b/server/src/nest/memories/unified.controller.ts new file mode 100644 index 00000000..0c277086 --- /dev/null +++ b/server/src/nest/memories/unified.controller.ts @@ -0,0 +1,123 @@ +import { Body, Controller, Delete, Get, Headers, HttpCode, Param, Post, Put, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; +import type { User } from '../../types'; +import type { Selection } from '../../services/memories/helpersService'; +import { MemoriesService } from './memories.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; + +/** + * /api/integrations/memories/unified — provider-agnostic trip photo + album-link + * management. + * + * Byte-identical to the legacy Express router (server/src/routes/memories/unified.ts): + * bare `authenticate` (JwtAuthGuard), success bodies on 200, and the per-result + * error envelope `{ error }` at `result.error.status` reused from the unified + * service. Lenient hand-rolled body coercion is preserved — no Zod here. + */ +@Controller('api/integrations/memories/unified') +@UseGuards(JwtAuthGuard) +export class UnifiedMemoriesController { + constructor(private readonly memories: MemoriesService) {} + + @Get('trips/:tripId/photos') + listPhotos(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void { + const result = this.memories.listTripPhotos(tripId, user.id); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ photos: result.data }); + } + + @Post('trips/:tripId/photos') + @HttpCode(200) + async addPhotos( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: Record, + @Headers('x-socket-id') sid: string, + @Res() res: Response, + ): Promise { + const selections: Selection[] = Array.isArray(body?.selections) ? (body.selections as Selection[]) : []; + const shared = body?.shared === undefined ? true : !!body?.shared; + const result = await this.memories.addTripPhotos(tripId, user.id, shared, selections, sid); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ success: true, added: result.data.added }); + } + + @Put('trips/:tripId/photos/sharing') + async setSharing( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: Record, + @Res() res: Response, + ): Promise { + const result = await this.memories.setTripPhotoSharing(tripId, user.id, Number(body?.photo_id), body?.shared as boolean); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ success: true }); + } + + @Delete('trips/:tripId/photos') + async removePhoto( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: Record, + @Res() res: Response, + ): Promise { + const result = this.memories.removeTripPhoto(tripId, user.id, Number(body?.photo_id)); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ success: true }); + } + + @Get('trips/:tripId/album-links') + listAlbumLinks(@CurrentUser() user: User, @Param('tripId') tripId: string, @Res() res: Response): void { + const result = this.memories.listTripAlbumLinks(tripId, user.id); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ links: result.data }); + } + + @Post('trips/:tripId/album-links') + @HttpCode(200) + createAlbumLink( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Body() body: Record, + @Res() res: Response, + ): void { + const passphrase = body?.passphrase ? String(body.passphrase) : undefined; + const result = this.memories.createTripAlbumLink(tripId, user.id, body?.provider, body?.album_id, body?.album_name, passphrase); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ success: true }); + } + + @Delete('trips/:tripId/album-links/:linkId') + removeAlbumLink( + @CurrentUser() user: User, + @Param('tripId') tripId: string, + @Param('linkId') linkId: string, + @Res() res: Response, + ): void { + const result = this.memories.removeAlbumLink(tripId, linkId, user.id); + if ('error' in result) { + res.status(result.error.status).json({ error: result.error.message }); + return; + } + res.json({ success: true }); + } +} diff --git a/server/src/app.ts b/server/src/nest/platform/platform.routes.ts similarity index 51% rename from server/src/app.ts rename to server/src/nest/platform/platform.routes.ts index e6cf6fe4..8b207f37 100644 --- a/server/src/app.ts +++ b/server/src/nest/platform/platform.routes.ts @@ -2,62 +2,39 @@ import express, { Request, Response, NextFunction } from 'express'; import path from 'node:path'; import fs from 'node:fs'; -import multer from 'multer'; -import { authenticate, verifyJwtAndLoadUser } from './middleware/auth'; -import { applyGlobalMiddleware } from './middleware/globalMiddleware'; -import { db } from './db/database'; - -import authRoutes from './routes/auth'; -import tripsRoutes from './routes/trips'; -import daysRoutes, { accommodationsRouter as accommodationsRoutes } from './routes/days'; -import placesRoutes from './routes/places'; -import assignmentsRoutes from './routes/assignments'; -import packingRoutes from './routes/packing'; -import todoRoutes from './routes/todo'; -import tagsRoutes from './routes/tags'; -import categoriesRoutes from './routes/categories'; -import adminRoutes from './routes/admin'; -import mapsRoutes from './routes/maps'; -import airportsRoutes from './routes/airports'; -import filesRoutes from './routes/files'; -import reservationsRoutes from './routes/reservations'; -import dayNotesRoutes from './routes/dayNotes'; -import settingsRoutes from './routes/settings'; -import budgetRoutes from './routes/budget'; -import collabRoutes from './routes/collab'; -import backupRoutes from './routes/backup'; -import oidcRoutes from './routes/oidc'; -import { oauthPublicRouter, oauthApiRouter } from './routes/oauth'; -import vacayRoutes from './routes/vacay'; -import atlasRoutes from './routes/atlas'; -import memoriesRoutes from './routes/memories/unified'; -import photoRoutes from './routes/photos'; -import notificationRoutes from './routes/notifications'; -import shareRoutes from './routes/share'; -import journeyRoutes from './routes/journey'; -import journeyPublicRoutes from './routes/journeyPublic'; -import publicConfigRoutes from './routes/publicConfig'; -import systemNoticesRoutes from './routes/systemNotices'; -import { mcpHandler } from './mcp'; -import { trekOAuthProvider, trekClientsStore } from './mcp/oauthProvider'; -import { Addon, AuthRequest } from './types'; -import { getUpcomingReservations } from './services/reservationService'; -import { getPhotoProviderConfig } from './services/memories/helpersService'; -import { getCollabFeatures } from './services/adminService'; -import { isAddonEnabled } from './services/adminService'; -import { ADDON_IDS } from './addons'; -import { ALL_SCOPES } from './mcp/scopes'; +import { verifyJwtAndLoadUser } from '../../middleware/auth'; +import { db } from '../../db/database'; +import { mcpHandler } from '../../mcp'; +import { trekOAuthProvider, trekClientsStore } from '../../mcp/oauthProvider'; +import { isAddonEnabled } from '../../services/adminService'; +import { ADDON_IDS } from '../../addons'; +import { ALL_SCOPES } from '../../mcp/scopes'; import { mcpAuthMetadataRouter } from '@modelcontextprotocol/sdk/server/auth/router'; import { authorizationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize'; import { clientRegistrationHandler } from '@modelcontextprotocol/sdk/server/auth/handlers/register'; import type { OAuthMetadata } from '@modelcontextprotocol/sdk/shared/auth'; -import { getMcpSafeUrl } from './services/notifications'; +import { getMcpSafeUrl } from '../../services/notifications'; -export function createApp(): express.Application { - const app = express(); +// Platform / transport routes extracted verbatim from createApp() (app.ts) so they can be +// mounted on either the legacy Express app or the NestJS Express instance (strangler A6/A8). +// +// IMPORTANT — path resolution: the original blocks lived in src/app.ts, where __dirname +// resolves to the directory of app.js (one level above the uploads/public anchor), so they +// used '../uploads/...' and '../public'. This file lives three levels deeper +// (src/nest/platform/), so __dirname is three levels deeper too. The relative prefixes are +// therefore '../../../uploads/...' and '../../../public' — which resolve to the EXACT same +// absolute paths as before. This is the only intentional change; everything else is byte-for-byte +// identical. (rootDir/outDir preserve the tree, so the offset holds in both source/test and +// compiled/dist execution — matching the other nest controllers that use '../../../uploads/...'.) - applyGlobalMiddleware(app); +const UPLOADS_DIR = path.join(__dirname, '../../../uploads'); +export const PUBLIC_DIR = path.join(__dirname, '../../../public'); +/** + * Static + guarded /uploads/* routes. Must be applied BEFORE the API route mounts + * (identical to its original position near the top of createApp). + */ +export function applyPlatformUploads(app: express.Application): void { // Static: avatars, covers, and journey photos. // // Security model (audit SEC-M9): these paths are unauthenticated by @@ -76,9 +53,9 @@ export function createApp(): express.Application { // not embedded in unauthenticated UI contexts, so that endpoint IS // gated (session JWT with pv, or a share token scoped to the photo's // trip). - app.use('/uploads/avatars', express.static(path.join(__dirname, '../uploads/avatars'))); - app.use('/uploads/covers', express.static(path.join(__dirname, '../uploads/covers'))); - app.use('/uploads/journey', express.static(path.join(__dirname, '../uploads/journey'))); + app.use('/uploads/avatars', express.static(path.join(UPLOADS_DIR, 'avatars'))); + app.use('/uploads/covers', express.static(path.join(UPLOADS_DIR, 'covers'))); + app.use('/uploads/journey', express.static(path.join(UPLOADS_DIR, 'journey'))); // Photos require either a valid logged-in session (via JWT with the // password_version gate) OR a share token that covers the SPECIFIC @@ -87,9 +64,9 @@ export function createApp(): express.Application { // unguessable, but the auth model was wrong. app.get('/uploads/photos/:filename', (req: Request, res: Response) => { const safeName = path.basename(req.params.filename); - const filePath = path.join(__dirname, '../uploads/photos', safeName); + const filePath = path.join(UPLOADS_DIR, 'photos', safeName); const resolved = path.resolve(filePath); - if (!resolved.startsWith(path.resolve(__dirname, '../uploads/photos'))) { + if (!resolved.startsWith(path.resolve(UPLOADS_DIR, 'photos'))) { return res.status(403).send('Forbidden'); } // existsSync here is cheap and avoids a sendFile error frame; kept @@ -122,116 +99,23 @@ export function createApp(): express.Application { app.use('/uploads/files', (_req: Request, res: Response) => { res.status(401).send('Authentication required'); }); +} - // API Routes - app.use('/api/auth', authRoutes); - app.use('/api/auth/oidc', oidcRoutes); - app.use('/api/trips', tripsRoutes); - app.use('/api/trips/:tripId/days', daysRoutes); - app.use('/api/trips/:tripId/accommodations', accommodationsRoutes); - app.use('/api/trips/:tripId/places', placesRoutes); - app.use('/api/trips/:tripId/packing', packingRoutes); - app.use('/api/trips/:tripId/todo', todoRoutes); - app.use('/api/trips/:tripId/files', filesRoutes); - app.use('/api/trips/:tripId/budget', budgetRoutes); - app.use('/api/trips/:tripId/collab', collabRoutes); - app.use('/api/trips/:tripId/reservations', reservationsRoutes); - app.get('/api/reservations/upcoming', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ reservations: getUpcomingReservations(authReq.user.id) }); - }); - app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); +/** + * Legacy /api/health handler, the OAuth/MCP SDK + transport wiring (well-known metadata, + * authorize/register SDK handlers, the COOP header, the /mcp routes), and the production + * SPA static + catch-all. Must be applied AFTER the API route mounts and BEFORE the global + * error handler (identical to its original position near the bottom of createApp). + * + * Note: the SDK metadata closures (getOAuthMetadata/getMetaRouter) and their lazy-init + * cache are kept module-local PER CALL so each app instance gets its own lazy state — the + * same as when they were function-local inside createApp. + */ +export function applyPlatformTransport(app: express.Application): void { app.get('/api/health', (_req: Request, res: Response) => { res.setHeader('Cache-Control', 'no-store, must-revalidate') res.json({ status: 'ok' }) }); - app.use('/api/config', publicConfigRoutes); - app.use('/api', assignmentsRoutes); - app.use('/api/tags', tagsRoutes); - app.use('/api/categories', categoriesRoutes); - app.use('/api/admin', adminRoutes); - - // Addons list endpoint - app.get('/api/addons', authenticate, (_req: Request, res: Response) => { - const addons = db.prepare('SELECT id, name, type, icon, enabled FROM addons WHERE enabled = 1 ORDER BY sort_order').all() as Pick[]; - const providers = db.prepare(` - SELECT id, name, icon, enabled, sort_order - FROM photo_providers - WHERE enabled = 1 - ORDER BY sort_order, id - `).all() as Array<{ id: string; name: string; icon: string; enabled: number; sort_order: number }>; - const fields = db.prepare(` - SELECT provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order - FROM photo_provider_fields - ORDER BY sort_order, id - `).all() as Array<{ - provider_id: string; - field_key: string; - label: string; - input_type: string; - placeholder?: string | null; - hint?: string | null; - required: number; - secret: number; - settings_key?: string | null; - payload_key?: string | null; - sort_order: number; - }>; - - const fieldsByProvider = new Map(); - for (const field of fields) { - const arr = fieldsByProvider.get(field.provider_id) || []; - arr.push(field); - fieldsByProvider.set(field.provider_id, arr); - } - - res.json({ - collabFeatures: getCollabFeatures(), - addons: [ - ...addons.map(a => ({ ...a, enabled: !!a.enabled })), - ...providers.map(p => ({ - id: p.id, - name: p.name, - type: 'photo_provider', - icon: p.icon, - enabled: !!p.enabled, - config: getPhotoProviderConfig(p.id), - fields: (fieldsByProvider.get(p.id) || []).map(f => ({ - key: f.field_key, - label: f.label, - input_type: f.input_type, - placeholder: f.placeholder || '', - hint: f.hint || null, - required: !!f.required, - secret: !!f.secret, - settings_key: f.settings_key || null, - payload_key: f.payload_key || null, - sort_order: f.sort_order, - })), - })), - ], - }); - }); - - // Addon routes - app.use('/api/addons/vacay', vacayRoutes); - app.use('/api/addons/atlas', atlasRoutes); - app.use('/api/journeys', (req, res, next) => { - if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' }); - next(); - }, journeyRoutes); - app.use('/api/public/journey', journeyPublicRoutes); - app.use('/api/integrations/memories', memoriesRoutes); - app.use('/api/photos', photoRoutes); - app.use('/api/maps', mapsRoutes); - app.use('/api/airports', airportsRoutes); - // /api/weather is served by the NestJS weather module (see src/nest/weather); - // the legacy Express route was decommissioned after the migration (L1). - app.use('/api/settings', settingsRoutes); - app.use('/api/system-notices', systemNoticesRoutes); - app.use('/api/backup', backupRoutes); - app.use('/api/notifications', notificationRoutes); - app.use('/api', shareRoutes); // OAuth 2.1 — public endpoints // Gate: 404 when MCP addon is disabled (M2 — prevents feature fingerprinting) @@ -240,10 +124,6 @@ export function createApp(): express.Application { next(); }; - // OAuth 2.1 — SPA-facing authenticated endpoints (/api/oauth/*) - // Mounted first: per-route 403 checks inside oauthApiRouter are the gate, not mcpAddonGate - app.use('/api/oauth', oauthApiRouter); - // SDK metadata router — built lazily on first request so getAppUrl() (which queries the DB) // is not called at createApp() time, before test tables have been created. // mcpAuthMetadataRouter serves: @@ -326,10 +206,6 @@ export function createApp(): express.Application { // SDK DCR handler: accepts registrations without scope (fixes issue #959 bug 2) app.use('/oauth/register', mcpAddonGate, clientRegistrationHandler({ clientsStore: trekClientsStore })); - // Token and revoke keep TREK's own handlers (timing-safe hash comparison not supported by SDK clientAuth) - // oauthPublicRouter has per-route isAddonEnabled checks; no blanket gate needed here - app.use('/', oauthPublicRouter); - // MCP endpoint app.post('/mcp', mcpHandler); app.get('/mcp', mcpHandler); @@ -349,39 +225,43 @@ export function createApp(): express.Application { res.setHeader('Cross-Origin-Opener-Policy', 'unsafe-none'); next(); }); +} - // Production static file serving - if (process.env.NODE_ENV === 'production') { - const publicPath = path.join(__dirname, '../public'); - app.use(express.static(publicPath, { +/** + * Production SPA serving: the built client static assets + the index.html catch-all + * for client-side routes. This is the LEGACY (plain Express 4) form — a real + * `app.get(catch-all)` registered as the terminal handler. The NestJS bootstrap can + * NOT use this (its router terminates unmatched requests with a 404 before any + * post-init route runs, and Express 5's path-to-regexp rejects a bare '*'); it serves + * the SPA via the SpaFallbackFilter instead. Both produce the identical result: + * unmatched GET → index.html in production. + */ +export function applyPlatformSpa(app: express.Application): void { + applyPlatformStatic(app); + if (process.env.NODE_ENV !== 'production') return; + // /.*/ rather than '*' so the helper is Express-4 and Express-5 safe. + app.get(/.*/, (_req: Request, res: Response) => { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(path.join(PUBLIC_DIR, 'index.html')); + }); +} + +/** + * Production static serving of the built client (JS/CSS/assets). Split out from + * applyPlatformSpa because the NestJS bootstrap needs the static files served + * BEFORE its router (so a real asset request returns the file, not the SPA + * index.html), while the index.html catch-all is handled separately (legacy: + * app.get catch-all; Nest: SpaFallbackFilter). No-op outside production. + */ +export function applyPlatformStatic(app: express.Application): void { + if (process.env.NODE_ENV !== 'production') return; + app.use( + express.static(PUBLIC_DIR, { setHeaders: (res, filePath) => { if (filePath.endsWith('index.html')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); } }, - })); - app.get('*', (_req: Request, res: Response) => { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.sendFile(path.join(publicPath, 'index.html')); - }); - } - - // Global error handler - app.use((err: Error & { status?: number; statusCode?: number }, _req: Request, res: Response, _next: NextFunction) => { - if (process.env.NODE_ENV === 'production') { - console.error('Unhandled error:', err.message); - } else { - console.error('Unhandled error:', err); - } - if (err instanceof multer.MulterError) { - const status = err.code === 'LIMIT_FILE_SIZE' ? 413 : 400; - return res.status(status).json({ error: err.message }); - } - const status = err.statusCode || err.status || 500; - // Expose the message for client errors (4xx); keep 'Internal server error' for 5xx. - const message = status < 500 ? err.message : 'Internal server error'; - res.status(status).json({ error: message }); - }); - - return app; -} \ No newline at end of file + }), + ); +} diff --git a/server/src/nest/platform/spa-fallback.filter.ts b/server/src/nest/platform/spa-fallback.filter.ts new file mode 100644 index 00000000..d1a12be2 --- /dev/null +++ b/server/src/nest/platform/spa-fallback.filter.ts @@ -0,0 +1,37 @@ +import { ArgumentsHost, Catch, ExceptionFilter, NotFoundException } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import path from 'node:path'; +import { PUBLIC_DIR } from './platform.routes'; + +/** + * Serves the built SPA (index.html) for any request the NestJS router did not + * match — the production single-page-app fallback. This replaces the legacy + * Express `app.get('*')` catch-all, which cannot run on the Nest instance: Nest's + * router terminates an unmatched request by throwing NotFoundException (it never + * falls through to a post-init Express route), so the SPA fallback has to live + * inside the Nest pipeline as a NotFound filter instead. + * + * Behaviour matches the legacy catch-all exactly: in production, an unmatched GET + * returns index.html; everything else (non-GET, or dev where there is no built + * client) keeps the standard TREK `{ error }` 404 envelope. The `@Catch(NotFoundException)` + * is more specific than the global TrekExceptionFilter, so Nest routes 404s here + * while every other error still flows through TrekExceptionFilter. + */ +@Catch(NotFoundException) +export class SpaFallbackFilter implements ExceptionFilter { + catch(exception: NotFoundException, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const req = ctx.getRequest(); + const res = ctx.getResponse(); + + if (process.env.NODE_ENV === 'production' && req.method === 'GET') { + res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); + res.sendFile(path.join(PUBLIC_DIR, 'index.html')); + return; + } + + // Non-production, or a non-GET miss: keep the standard TREK 404 envelope + // (identical to what TrekExceptionFilter produces for a NotFoundException). + res.status(404).json({ error: exception.message || 'Not Found' }); + } +} diff --git a/server/src/nest/reservations/reservations.module.ts b/server/src/nest/reservations/reservations.module.ts index 2ab9efcf..9401abde 100644 --- a/server/src/nest/reservations/reservations.module.ts +++ b/server/src/nest/reservations/reservations.module.ts @@ -3,13 +3,15 @@ import { ReservationsController } from './reservations.controller'; import { ReservationsService } from './reservations.service'; import { AccommodationsController } from './accommodations.controller'; import { AccommodationsService } from './accommodations.service'; +import { UpcomingReservationsController } from './upcoming-reservations.controller'; /** * Reservations + accommodations domain (S5 — Phase 2 trip sub-domain). - * Two mounts: /api/trips/:tripId/reservations and /accommodations. + * Mounts: /api/trips/:tripId/reservations, /accommodations, and the cross-trip + * /api/reservations/upcoming dashboard feed. */ @Module({ - controllers: [ReservationsController, AccommodationsController], + controllers: [ReservationsController, AccommodationsController, UpcomingReservationsController], providers: [ReservationsService, AccommodationsService], }) export class ReservationsModule {} diff --git a/server/src/nest/reservations/reservations.service.ts b/server/src/nest/reservations/reservations.service.ts index f8747f92..5d1d7cdc 100644 --- a/server/src/nest/reservations/reservations.service.ts +++ b/server/src/nest/reservations/reservations.service.ts @@ -34,6 +34,12 @@ export class ReservationsService { return svc.listReservations(tripId); } + // Cross-trip "upcoming reservations" feed (dashboard widget). Reuses the legacy + // query unchanged; the default limit (6) matches the legacy inline handler. + listUpcoming(userId: number) { + return svc.getUpcomingReservations(userId); + } + create(tripId: string, data: Parameters[1]) { return svc.createReservation(tripId, data); } diff --git a/server/src/nest/reservations/upcoming-reservations.controller.ts b/server/src/nest/reservations/upcoming-reservations.controller.ts new file mode 100644 index 00000000..fc794449 --- /dev/null +++ b/server/src/nest/reservations/upcoming-reservations.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Get, UseGuards } from '@nestjs/common'; +import type { User } from '../../types'; +import { ReservationsService } from './reservations.service'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; + +/** + * GET /api/reservations/upcoming — the cross-trip "upcoming reservations" feed + * (dashboard widget). Byte-identical to the legacy inline handler in + * server/src/app.ts (authenticate, returns { reservations: [...] }, limit 6). + * + * Separate from the trip-scoped ReservationsController + * (/api/trips/:tripId/reservations) because the base path differs. + */ +@Controller('api/reservations') +@UseGuards(JwtAuthGuard) +export class UpcomingReservationsController { + constructor(private readonly reservations: ReservationsService) {} + + @Get('upcoming') + upcoming(@CurrentUser() user: User) { + return { reservations: this.reservations.listUpcoming(user.id) }; + } +} diff --git a/server/src/nest/strangler.ts b/server/src/nest/strangler.ts deleted file mode 100644 index 2265e85f..00000000 --- a/server/src/nest/strangler.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Strangler toggle for the incremental NestJS migration. - * - * `getNestPrefixes()` returns the request path prefixes that NestJS handles; - * every other path falls through to the legacy Express app. The default is the - * set of prefixes whose Nest modules exist. Operators can override it at runtime - * via the `NEST_PREFIXES` env var (comma-separated) for instant Nest<->Express - * rollback — no redeploy, no code change. Setting `NEST_PREFIXES=` (empty) routes - * everything back to the legacy app. - */ -const DEFAULT_NEST_PREFIXES = [ - '/api/_nest', - '/api/weather', - '/api/airports', - '/api/config', - '/api/system-notices', - '/api/maps', - '/api/categories', - '/api/tags', - '/api/notifications', - '/api/addons/atlas', - '/api/addons/vacay', - '/api/trips/:tripId/packing', - '/api/trips/:tripId/todo', - '/api/trips/:tripId/budget', - '/api/trips/:tripId/reservations', - '/api/trips/:tripId/accommodations', - '/api/trips/:tripId/days', - '/api/trips/:tripId/assignments', - '/api/trips/:tripId/places', - '/api/trips/:tripId/collab', - '/api/trips/:tripId/files', - '/api/photos', - '/api/journeys', - '/api/public/journey', - '/api/shared', - '/api/settings', - '/api/backup', - // Auth — listed as explicit sub-paths (rather than one broad /api/auth prefix) - // so each endpoint was flipped to Nest individually as it was migrated. All - // current /api/auth/* endpoints below, including /api/auth/oidc, are handled - // by Nest; nothing here falls through to Express anymore. - '/api/auth/app-config', - '/api/auth/demo-login', - '/api/auth/invite', - '/api/auth/register', - '/api/auth/login', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/me', - '/api/auth/logout', - '/api/auth/avatar', - '/api/auth/users', - '/api/auth/validate-keys', - '/api/auth/app-settings', - '/api/auth/travel-stats', - '/api/auth/mfa', - '/api/auth/mcp-tokens', - '/api/auth/ws-token', - '/api/auth/resource-token', - '/api/auth/oidc', - '/api/oauth', - // OAuth public endpoints — explicit so the SDK-mounted /oauth/authorize, - // /oauth/register and /oauth/consent keep falling through to Express. - '/oauth/token', - '/oauth/userinfo', - '/oauth/revoke', - '/api/admin', - '/api/trips/:tripId/share-link', - '/api/trips|', - '/api/trips/:tripId|', - '/api/trips/:tripId/members', - '/api/trips/:tripId/cover', - '/api/trips/:tripId/copy', - '/api/trips/:tripId/bundle', - '/api/trips/:tripId/export.ics', -]; - -export function getNestPrefixes(): string[] { - const raw = process.env.NEST_PREFIXES; - if (raw !== undefined) { - return raw.split(',').map((s) => s.trim()).filter(Boolean); - } - return DEFAULT_NEST_PREFIXES; -} - -function escapeRegExp(segment: string): string { - return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Turns one prefix into a matcher. - * - * - A static prefix (no `:param`) uses a plain exact/sub-path match. - * - A pattern prefix containing `:param` segments — needed for trip-scoped - * routes like `/api/trips/:tripId/packing`, where the legacy mount sits - * between dynamic ids — compiles to a regex in which each `:param` matches - * exactly one path segment, so a single nested mount routes to Nest without - * capturing sibling routes (days, places, ...) still served by Express. - * - A trailing `|` marks the prefix as EXACT — it matches that path only, NOT - * its sub-paths. This is what lets an aggregate-root like `/api/trips` migrate - * (its own /api/trips and /api/trips/:id routes) without swallowing the - * not-yet-migrated nested mounts (/api/trips/:id/collab, /files, ...). - */ -function prefixToMatcher(prefix: string): (path: string) => boolean { - const exact = prefix.endsWith('|'); - const p = exact ? prefix.slice(0, -1) : prefix; - - if (!p.includes(':')) { - if (exact) return (path) => path === p; - return (path) => path === p || path.startsWith(p + '/'); - } - - const pattern = p - .split('/') - .map((segment) => (segment.startsWith(':') ? '[^/]+' : escapeRegExp(segment))) - .join('/'); - const re = new RegExp(exact ? `^${pattern}$` : `^${pattern}(?:/.*)?$`); - return (path) => re.test(path); -} - -/** Builds a matcher: true when `path` belongs to one of the migrated prefixes. */ -export function makeNestPathMatcher(prefixes: string[]): (path: string) => boolean { - const matchers = prefixes.map(prefixToMatcher); - return (path) => matchers.some((matches) => matches(path)); -} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts deleted file mode 100644 index 16a5b40b..00000000 --- a/server/src/routes/admin.ts +++ /dev/null @@ -1,475 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate, adminOnly } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; -import * as svc from '../services/adminService'; -import { getAdminUserDefaults, setAdminUserDefaults } from '../services/settingsService'; -import { invalidateMcpSessions } from '../mcp'; -import { getPreferencesMatrix, setAdminPreferences } from '../services/notificationPreferencesService'; - -const router = express.Router(); - -router.use(authenticate, adminOnly); - -// ── User CRUD ────────────────────────────────────────────────────────────── - -router.get('/users', (_req: Request, res: Response) => { - res.json({ users: svc.listUsers() }); -}); - -router.post('/users', (req: Request, res: Response) => { - const result = svc.createUser(req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.user_create', - resource: String(result.insertedId), - ip: getClientIp(req), - details: result.auditDetails, - }); - res.status(201).json({ user: result.user }); -}); - -router.put('/users/:id', (req: Request, res: Response) => { - const result = svc.updateUser(req.params.id, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.user_update', - resource: String(req.params.id), - ip: getClientIp(req), - details: { targetUser: result.previousEmail, fields: result.changed }, - }); - logInfo(`Admin ${authReq.user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`); - res.json({ user: result.user }); -}); - -router.delete('/users/:id', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.deleteUser(req.params.id, authReq.user.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - writeAudit({ - userId: authReq.user.id, - action: 'admin.user_delete', - resource: String(req.params.id), - ip: getClientIp(req), - details: { targetUser: result.email }, - }); - logInfo(`Admin ${authReq.user.email} deleted user ${result.email}`); - res.json({ success: true }); -}); - -// ── Stats ────────────────────────────────────────────────────────────────── - -router.get('/stats', (_req: Request, res: Response) => { - res.json(svc.getStats()); -}); - -// ── Permissions ──────────────────────────────────────────────────────────── - -router.get('/permissions', (_req: Request, res: Response) => { - res.json(svc.getPermissions()); -}); - -router.put('/permissions', (req: Request, res: Response) => { - const { permissions } = req.body; - if (!permissions || typeof permissions !== 'object') { - return res.status(400).json({ error: 'permissions object required' }); - } - const authReq = req as AuthRequest; - const result = svc.savePermissions(permissions); - writeAudit({ - userId: authReq.user.id, - action: 'admin.permissions_update', - resource: 'permissions', - ip: getClientIp(req), - details: permissions, - }); - res.json({ success: true, permissions: result.permissions, ...(result.skipped.length ? { skipped: result.skipped } : {}) }); -}); - -// ── Audit Log ────────────────────────────────────────────────────────────── - -router.get('/audit-log', (req: Request, res: Response) => { - res.json(svc.getAuditLog(req.query as { limit?: string; offset?: string })); -}); - -// ── OIDC Settings ────────────────────────────────────────────────────────── - -router.get('/oidc', (_req: Request, res: Response) => { - res.json(svc.getOidcSettings()); -}); - -router.put('/oidc', (req: Request, res: Response) => { - const result = svc.updateOidcSettings(req.body); - if (result.error) { - return res.status(result.status || 400).json({ error: result.error }); - } - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.oidc_update', - ip: getClientIp(req), - details: { issuer_set: !!req.body.issuer }, - }); - res.json({ success: true }); -}); - -// ── Demo Baseline ────────────────────────────────────────────────────────── - -router.post('/save-demo-baseline', (req: Request, res: Response) => { - const result = svc.saveDemoBaseline(); - if (result.error) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ userId: authReq.user.id, action: 'admin.demo_baseline_save', ip: getClientIp(req) }); - res.json({ success: true, message: result.message }); -}); - -// ── GitHub / Version ─────────────────────────────────────────────────────── - -router.get('/github-releases', async (req: Request, res: Response) => { - const { per_page = '10', page = '1' } = req.query; - res.json(await svc.getGithubReleases(String(per_page), String(page))); -}); - -router.get('/version-check', async (_req: Request, res: Response) => { - res.json(await svc.checkVersion()); -}); - -// ── Admin notification preferences ──────────────────────────────────────── - -router.get('/notification-preferences', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin')); -}); - -router.put('/notification-preferences', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - setAdminPreferences(authReq.user.id, req.body); - res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'admin')); -}); - -// ── Invite Tokens ────────────────────────────────────────────────────────── - -router.get('/invites', (_req: Request, res: Response) => { - res.json({ invites: svc.listInvites() }); -}); - -router.post('/invites', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.createInvite(authReq.user.id, req.body); - writeAudit({ - userId: authReq.user.id, - action: 'admin.invite_create', - resource: String(result.inviteId), - ip: getClientIp(req), - details: { max_uses: result.uses, expires_in_days: result.expiresInDays }, - }); - res.status(201).json({ invite: result.invite }); -}); - -router.delete('/invites/:id', (req: Request, res: Response) => { - const result = svc.deleteInvite(req.params.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.invite_delete', - resource: String(req.params.id), - ip: getClientIp(req), - }); - res.json({ success: true }); -}); - -// ── Bag Tracking ─────────────────────────────────────────────────────────── - -router.get('/bag-tracking', (_req: Request, res: Response) => { - res.json(svc.getBagTracking()); -}); - -router.put('/bag-tracking', (req: Request, res: Response) => { - const result = svc.updateBagTracking(req.body.enabled); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.bag_tracking', - ip: getClientIp(req), - details: { enabled: result.enabled }, - }); - res.json(result); -}); - -// ── Places Photos ─────────────────────────────────────────────────────── - -router.get('/places-photos', (_req: Request, res: Response) => { - res.json(svc.getPlacesPhotos()); -}); - -router.put('/places-photos', (req: Request, res: Response) => { - if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); - const result = svc.updatePlacesPhotos(req.body.enabled); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.places_photos', - ip: getClientIp(req), - details: { enabled: result.enabled }, - }); - res.json(result); -}); - -// ── Places Autocomplete ────────────────────────────────────────────────── - -router.get('/places-autocomplete', (_req: Request, res: Response) => { - res.json(svc.getPlacesAutocomplete()); -}); - -router.put('/places-autocomplete', (req: Request, res: Response) => { - if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); - const result = svc.updatePlacesAutocomplete(req.body.enabled); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.places_autocomplete', - ip: getClientIp(req), - details: { enabled: result.enabled }, - }); - res.json(result); -}); - -// ── Places Details ─────────────────────────────────────────────────────── - -router.get('/places-details', (_req: Request, res: Response) => { - res.json(svc.getPlacesDetails()); -}); - -router.put('/places-details', (req: Request, res: Response) => { - if (typeof req.body.enabled !== 'boolean') return res.status(400).json({ error: 'enabled must be a boolean' }); - const result = svc.updatePlacesDetails(req.body.enabled); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.places_details', - ip: getClientIp(req), - details: { enabled: result.enabled }, - }); - res.json(result); -}); - -// ── Collab Features ─────────────────────────────────────────────────────── - -router.get('/collab-features', (_req: Request, res: Response) => { - res.json(svc.getCollabFeatures()); -}); - -router.put('/collab-features', (req: Request, res: Response) => { - const result = svc.updateCollabFeatures(req.body); - invalidateMcpSessions(); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.collab_features', - ip: getClientIp(req), - details: result, - }); - res.json(result); -}); - -// ── Packing Templates ────────────────────────────────────────────────────── - -router.get('/packing-templates', (_req: Request, res: Response) => { - res.json({ templates: svc.listPackingTemplates() }); -}); - -router.get('/packing-templates/:id', (req: Request, res: Response) => { - const result = svc.getPackingTemplate(req.params.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json(result); -}); - -router.post('/packing-templates', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.createPackingTemplate(req.body.name, authReq.user.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.status(201).json(result); -}); - -router.put('/packing-templates/:id', (req: Request, res: Response) => { - const result = svc.updatePackingTemplate(req.params.id, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json(result); -}); - -router.delete('/packing-templates/:id', (req: Request, res: Response) => { - const result = svc.deletePackingTemplate(req.params.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.packing_template_delete', - resource: String(req.params.id), - ip: getClientIp(req), - details: { name: result.name }, - }); - res.json({ success: true }); -}); - -// Template categories - -router.post('/packing-templates/:id/categories', (req: Request, res: Response) => { - const result = svc.createTemplateCategory(req.params.id, req.body.name); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.status(201).json(result); -}); - -router.put('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => { - const result = svc.updateTemplateCategory(req.params.templateId, req.params.catId, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json(result); -}); - -router.delete('/packing-templates/:templateId/categories/:catId', (req: Request, res: Response) => { - const result = svc.deleteTemplateCategory(req.params.templateId, req.params.catId); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -// Template items - -router.post('/packing-templates/:templateId/categories/:catId/items', (req: Request, res: Response) => { - const result = svc.createTemplateItem(req.params.templateId, req.params.catId, req.body.name); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.status(201).json(result); -}); - -router.put('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => { - const result = svc.updateTemplateItem(req.params.itemId, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json(result); -}); - -router.delete('/packing-templates/:templateId/items/:itemId', (req: Request, res: Response) => { - const result = svc.deleteTemplateItem(req.params.itemId); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -// ── Addons ───────────────────────────────────────────────────────────────── - -router.get('/addons', (_req: Request, res: Response) => { - res.json({ addons: svc.listAddons() }); -}); - -router.put('/addons/:id', (req: Request, res: Response) => { - const result = svc.updateAddon(req.params.id, req.body); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.addon_update', - resource: String(req.params.id), - ip: getClientIp(req), - details: result.auditDetails, - }); - // Invalidate all MCP sessions so they re-create with the updated addon tool set - invalidateMcpSessions(); - res.json({ addon: result.addon }); -}); - -// ── MCP Tokens ───────────────────────────────────────────────────────────── - -router.get('/mcp-tokens', (_req: Request, res: Response) => { - res.json({ tokens: svc.listMcpTokens() }); -}); - -router.delete('/mcp-tokens/:id', (req: Request, res: Response) => { - const result = svc.deleteMcpToken(req.params.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -// ── OAuth Sessions ───────────────────────────────────────────────────────── - -router.get('/oauth-sessions', (_req: Request, res: Response) => { - res.json({ sessions: svc.listOAuthSessions() }); -}); - -router.delete('/oauth-sessions/:id', (req: Request, res: Response) => { - const result = svc.revokeOAuthSession(req.params.id); - if ('error' in result) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.oauth_session.revoke', - resource: String(req.params.id), - ip: getClientIp(req), - }); - res.json({ success: true }); -}); - -// ── JWT Rotation ─────────────────────────────────────────────────────────── - -router.post('/rotate-jwt-secret', (req: Request, res: Response) => { - const result = svc.rotateJwtSecret(); - if (result.error) return res.status(result.status!).json({ error: result.error }); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.rotate_jwt_secret', - ip: getClientIp(req), - }); - res.json({ success: true }); -}); - -// ── Default User Settings ────────────────────────────────────────────────────── - -router.get('/default-user-settings', (_req: Request, res: Response) => { - res.json(getAdminUserDefaults()); -}); - -router.put('/default-user-settings', (req: Request, res: Response) => { - if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) { - return res.status(400).json({ error: 'Object body required' }); - } - try { - setAdminUserDefaults(req.body); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'admin.default_user_settings_update', - ip: getClientIp(req), - details: req.body, - }); - res.json(getAdminUserDefaults()); - } catch (err: any) { - res.status(400).json({ error: err.message }); - } -}); - -// ── Dev-only: test notification endpoints ────────────────────────────────────── -if (process.env.NODE_ENV?.toLowerCase() === 'development') { - const { send } = require('../services/notificationService'); - - router.post('/dev/test-notification', async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { event = 'trip_reminder', scope = 'user', targetId, params = {}, inApp } = req.body; - - try { - await send({ - event, - actorId: authReq.user.id, - scope, - targetId: targetId ?? authReq.user.id, - params: { actor: authReq.user.email, ...params }, - inApp, - }); - res.json({ success: true }); - } catch (err: any) { - res.status(400).json({ error: err.message }); - } - }); -} - -export default router; diff --git a/server/src/routes/airports.ts b/server/src/routes/airports.ts deleted file mode 100644 index 781ca982..00000000 --- a/server/src/routes/airports.ts +++ /dev/null @@ -1,19 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { searchAirports, findByIata } from '../services/airportService'; - -const router = express.Router(); - -router.get('/search', authenticate, (req: Request, res: Response) => { - const q = typeof req.query.q === 'string' ? req.query.q : ''; - if (!q) return res.json([]); - res.json(searchAirports(q)); -}); - -router.get('/:iata', authenticate, (req: Request, res: Response) => { - const airport = findByIata(req.params.iata); - if (!airport) return res.status(404).json({ error: 'Airport not found' }); - res.json(airport); -}); - -export default router; diff --git a/server/src/routes/assignments.ts b/server/src/routes/assignments.ts deleted file mode 100644 index ae7abef8..00000000 --- a/server/src/routes/assignments.ts +++ /dev/null @@ -1,137 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { requireTripAccess } from '../middleware/tripAccess'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { - getAssignmentWithPlace, - listDayAssignments, - dayExists, - placeExists, - createAssignment, - assignmentExistsInDay, - deleteAssignment, - reorderAssignments, - getAssignmentForTrip, - moveAssignment, - getParticipants, - updateTime, - setParticipants, -} from '../services/assignmentService'; -import { onPlaceCreated } from '../services/journeyService'; -import { AuthRequest } from '../types'; - -const router = express.Router({ mergeParams: true }); - -router.get('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, dayId } = req.params; - - if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); - - const result = listDayAssignments(dayId); - res.json({ assignments: result }); -}); - -router.post('/trips/:tripId/days/:dayId/assignments', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, dayId } = req.params; - const { place_id, notes } = req.body; - - if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); - if (!placeExists(place_id, tripId)) return res.status(404).json({ error: 'Place not found' }); - - const assignment = createAssignment(dayId, place_id, notes); - res.status(201).json({ assignment }); - broadcast(tripId, 'assignment:created', { assignment }, req.headers['x-socket-id'] as string); - try { onPlaceCreated(Number(tripId), Number(place_id)); } catch {} -}); - -router.delete('/trips/:tripId/days/:dayId/assignments/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, dayId, id } = req.params; - - if (!assignmentExistsInDay(id, dayId, tripId)) return res.status(404).json({ error: 'Assignment not found' }); - - deleteAssignment(id); - res.json({ success: true }); - broadcast(tripId, 'assignment:deleted', { assignmentId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/days/:dayId/assignments/reorder', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, dayId } = req.params; - const { orderedIds } = req.body; - - if (!dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); - - reorderAssignments(dayId, orderedIds); - res.json({ success: true }); - broadcast(tripId, 'assignment:reordered', { dayId: Number(dayId), orderedIds }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/assignments/:id/move', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - const { new_day_id, order_index } = req.body; - - const existing = getAssignmentForTrip(id, tripId); - if (!existing) return res.status(404).json({ error: 'Assignment not found' }); - - if (!dayExists(new_day_id, tripId)) return res.status(404).json({ error: 'Target day not found' }); - - const oldDayId = existing.day_id; - const { assignment: updated } = moveAssignment(id, new_day_id, order_index, oldDayId); - res.json({ assignment: updated }); - broadcast(tripId, 'assignment:moved', { assignment: updated, oldDayId: Number(oldDayId), newDayId: Number(new_day_id) }, req.headers['x-socket-id'] as string); -}); - -router.get('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { id } = req.params; - - const participants = getParticipants(id); - res.json({ participants }); -}); - -router.put('/trips/:tripId/assignments/:id/time', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - const existing = getAssignmentForTrip(id, tripId); - if (!existing) return res.status(404).json({ error: 'Assignment not found' }); - - const { place_time, end_time } = req.body; - const updated = updateTime(id, place_time, end_time); - res.json({ assignment: updated }); - broadcast(Number(tripId), 'assignment:updated', { assignment: updated }, req.headers['x-socket-id'] as string); -}); - -router.put('/trips/:tripId/assignments/:id/participants', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - const { user_ids } = req.body; - if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' }); - - const participants = setParticipants(id, user_ids); - res.json({ participants }); - broadcast(Number(tripId), 'assignment:participants', { assignmentId: Number(id), participants }, req.headers['x-socket-id'] as string); -}); - -export default router; diff --git a/server/src/routes/atlas.ts b/server/src/routes/atlas.ts deleted file mode 100644 index d5af8d86..00000000 --- a/server/src/routes/atlas.ts +++ /dev/null @@ -1,105 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { - getStats, - getCountryPlaces, - markCountryVisited, - unmarkCountryVisited, - markRegionVisited, - unmarkRegionVisited, - getVisitedRegions, - getRegionGeo, - listBucketList, - createBucketItem, - updateBucketItem, - deleteBucketItem, -} from '../services/atlasService'; - -const router = express.Router(); -router.use(authenticate); - -router.get('/stats', async (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const data = await getStats(userId); - res.json(data); -}); - -router.get('/regions', async (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - res.setHeader('Cache-Control', 'no-cache, no-store'); - const data = await getVisitedRegions(userId); - res.json(data); -}); - -router.get('/regions/geo', async (req: Request, res: Response) => { - const countries = (req.query.countries as string || '').split(',').filter(Boolean); - if (countries.length === 0) return res.json({ type: 'FeatureCollection', features: [] }); - const geo = await getRegionGeo(countries); - res.setHeader('Cache-Control', 'public, max-age=86400'); - res.json(geo); -}); - -router.get('/country/:code', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const code = req.params.code.toUpperCase(); - res.json(getCountryPlaces(userId, code)); -}); - -router.post('/country/:code/mark', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - markCountryVisited(userId, req.params.code.toUpperCase()); - res.json({ success: true }); -}); - -router.delete('/country/:code/mark', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - unmarkCountryVisited(userId, req.params.code.toUpperCase()); - res.json({ success: true }); -}); - -router.post('/region/:code/mark', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const { name, country_code } = req.body; - if (!name || !country_code) return res.status(400).json({ error: 'name and country_code are required' }); - markRegionVisited(userId, req.params.code.toUpperCase(), name, country_code.toUpperCase()); - res.json({ success: true }); -}); - -router.delete('/region/:code/mark', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - unmarkRegionVisited(userId, req.params.code.toUpperCase()); - res.json({ success: true }); -}); - -// ── Bucket List ───────────────────────────────────────────────────────────── - -router.get('/bucket-list', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - res.json({ items: listBucketList(userId) }); -}); - -router.post('/bucket-list', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const { name, lat, lng, country_code, notes, target_date } = req.body; - if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); - const item = createBucketItem(userId, { name, lat, lng, country_code, notes, target_date }); - res.status(201).json({ item }); -}); - -router.put('/bucket-list/:id', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const { name, notes, lat, lng, country_code, target_date } = req.body; - const item = updateBucketItem(userId, req.params.id, { name, notes, lat, lng, country_code, target_date }); - if (!item) return res.status(404).json({ error: 'Item not found' }); - res.json({ item }); -}); - -router.delete('/bucket-list/:id', (req: Request, res: Response) => { - const userId = (req as AuthRequest).user.id; - const deleted = deleteBucketItem(userId, req.params.id); - if (!deleted) return res.status(404).json({ error: 'Item not found' }); - res.json({ success: true }); -}); - -export default router; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts deleted file mode 100644 index d124ad44..00000000 --- a/server/src/routes/auth.ts +++ /dev/null @@ -1,413 +0,0 @@ -import express, { Request, Response, NextFunction } from 'express'; -import multer from 'multer'; -import path from 'path'; -import fs from 'fs'; -import { v4 as uuid } from 'uuid'; -import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth'; -import { AuthRequest, OptionalAuthRequest } from '../types'; -import { writeAudit, getClientIp } from '../services/auditLog'; -import { setAuthCookie, clearAuthCookie } from '../services/cookie'; -import { - getAppConfig, - demoLogin, - validateInviteToken, - registerUser, - loginUser, - getCurrentUser, - changePassword, - deleteAccount, - updateMapsKey, - updateApiKeys, - updateSettings, - getSettings, - saveAvatar, - deleteAvatar, - listUsers, - validateKeys, - getAppSettings, - updateAppSettings, - getTravelStats, - setupMfa, - enableMfa, - disableMfa, - verifyMfaLogin, - listMcpTokens, - createMcpToken, - deleteMcpToken, - createWsToken, - createResourceToken, - requestPasswordReset, - resetPassword, -} from '../services/authService'; -import { sendPasswordResetEmail, getAppUrl } from '../services/notifications'; - -const router = express.Router(); - -// --------------------------------------------------------------------------- -// Avatar upload (multer config stays in route — middleware concern) -// --------------------------------------------------------------------------- - -const avatarDir = path.join(__dirname, '../../uploads/avatars'); -if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); - -const avatarStorage = multer.diskStorage({ - destination: (_req, _file, cb) => cb(null, avatarDir), - filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)), -}); -const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; -const MAX_AVATAR_SIZE = 5 * 1024 * 1024; -const avatarUpload = multer({ - storage: avatarStorage, - limits: { fileSize: MAX_AVATAR_SIZE }, - fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) { - const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed'); - err.statusCode = 400; - return cb(err); - } - cb(null, true); - }, -}); - -// --------------------------------------------------------------------------- -// Rate limiter (middleware concern — stays in route) -// --------------------------------------------------------------------------- - -const RATE_LIMIT_WINDOW = 15 * 60 * 1000; -const RATE_LIMIT_CLEANUP = 5 * 60 * 1000; - -const loginAttempts = new Map(); -const mfaAttempts = new Map(); -const forgotAttempts = new Map(); -const resetAttempts = new Map(); -setInterval(() => { - const now = Date.now(); - for (const [key, record] of loginAttempts) { - if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key); - } - for (const [key, record] of mfaAttempts) { - if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key); - } - for (const [key, record] of forgotAttempts) { - if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key); - } - for (const [key, record] of resetAttempts) { - if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key); - } -}, RATE_LIMIT_CLEANUP); - -function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) { - return (req: Request, res: Response, next: NextFunction) => { - const key = req.ip || 'unknown'; - const now = Date.now(); - const record = store.get(key); - if (record && record.count >= maxAttempts && now - record.first < windowMs) { - return res.status(429).json({ error: 'Too many attempts. Please try again later.' }); - } - if (!record || now - record.first >= windowMs) { - store.set(key, { count: 1, first: now }); - } else { - record.count++; - } - next(); - }; -} -const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW); -const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts); -const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts); -const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts); - -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- - -router.get('/app-config', optionalAuth, (req: Request, res: Response) => { - const user = (req as OptionalAuthRequest).user; - res.json(getAppConfig(user)); -}); - -router.post('/demo-login', (req: Request, res: Response) => { - const result = demoLogin(); - if (result.error) return res.status(result.status!).json({ error: result.error }); - setAuthCookie(res, result.token!, req); - res.json({ token: result.token, user: result.user }); -}); - -router.get('/invite/:token', authLimiter, (req: Request, res: Response) => { - const result = validateInviteToken(req.params.token); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at }); -}); - -router.post('/register', authLimiter, (req: Request, res: Response) => { - const result = registerUser(req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails }); - setAuthCookie(res, result.token!, req); - res.status(201).json({ token: result.token, user: result.user }); -}); - -router.post('/login', authLimiter, async (req: Request, res: Response) => { - const started = Date.now(); - const result = loginUser(req.body); - if (result.auditAction) { - writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails }); - } - const elapsed = Date.now() - started; - if (elapsed < LOGIN_MIN_LATENCY_MS) { - await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed)); - } - if (result.error) return res.status(result.status!).json({ error: result.error }); - if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token }); - setAuthCookie(res, result.token!, req); - res.json({ token: result.token, user: result.user }); -}); - -// --------------------------------------------------------------------------- -// Password reset (forgot / complete) -// --------------------------------------------------------------------------- - -// Generic OK response — identical regardless of email existence, to -// prevent enumeration via response body OR status code. -const GENERIC_FORGOT_RESPONSE = { ok: true }; -// Minimum time we spend inside the forgot/login handlers so a "no such -// user" path does not complete noticeably faster than a real operation. -const FORGOT_MIN_LATENCY_MS = 350; -const LOGIN_MIN_LATENCY_MS = 350; - -router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => { - const started = Date.now(); - const rawEmail = typeof req.body?.email === 'string' ? req.body.email : ''; - const ip = getClientIp(req); - - const outcome = requestPasswordReset(rawEmail, ip); - - if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) { - // Build the reset URL from the server-side canonical APP_URL (or - // first ALLOWED_ORIGINS entry) — never from request headers. A - // crafted `Origin` / `Host` / `Referer` would otherwise put an - // attacker-controlled domain into the emailed reset link while the - // token itself is still legitimate. - const origin = getAppUrl(); - const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`; - - // Audit the REQUEST always — even for "no user" — so abuse is visible. - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } }); - - try { - const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId); - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } }); - } catch (err) { - // Never surface delivery failure to the caller — still respond ok. - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } }); - } - } else { - writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } }); - } - - // Pad the response so timing doesn't reveal outcome. - const elapsed = Date.now() - started; - if (elapsed < FORGOT_MIN_LATENCY_MS) { - await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed)); - } - res.json(GENERIC_FORGOT_RESPONSE); -}); - -router.post('/reset-password', resetLimiter, (req: Request, res: Response) => { - const ip = getClientIp(req); - const result = resetPassword(req.body); - if (result.error) { - writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } }); - return res.status(result.status!).json({ error: result.error }); - } - if (result.mfa_required) { - return res.status(200).json({ mfa_required: true }); - } - writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip }); - // Purposefully do NOT auto-login — the user just demonstrated they - // have email+password access; asking them to sign in fresh is the - // standard, safer UX. - res.json({ success: true }); -}); - -router.get('/me', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const user = getCurrentUser(authReq.user.id); - if (!user) return res.status(404).json({ error: 'User not found' }); - res.json({ user }); -}); - -router.post('/logout', (req: Request, res: Response) => { - clearAuthCookie(res, req); - res.json({ success: true }); -}); - -router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = changePassword(authReq.user.id, authReq.user.email, req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) }); - res.json({ success: true }); -}); - -router.delete('/me', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) }); - res.json({ success: true }); -}); - -router.put('/me/maps-key', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key)); -}); - -router.put('/me/api-keys', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(updateApiKeys(authReq.user.id, req.body)); -}); - -router.put('/me/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = updateSettings(authReq.user.id, req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: result.success, user: result.user }); -}); - -router.get('/me/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = getSettings(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ settings: result.settings }); -}); - -router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!req.file) return res.status(400).json({ error: 'No image uploaded' }); - res.json(await saveAvatar(authReq.user.id, req.file.filename)); -}); - -router.delete('/avatar', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(await deleteAvatar(authReq.user.id)); -}); - -router.get('/users', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ users: listUsers(authReq.user.id) }); -}); - -router.get('/validate-keys', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await validateKeys(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details }); -}); - -router.get('/app-settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = getAppSettings(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json(result.data); -}); - -router.put('/app-settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = updateAppSettings(authReq.user.id, req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ - userId: authReq.user.id, - action: 'settings.app_update', - ip: getClientIp(req), - details: result.auditSummary, - debugDetails: result.auditDebugDetails, - }); - res.json({ success: true }); -}); - -router.get('/travel-stats', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(getTravelStats(authReq.user.id)); -}); - -router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => { - const result = verifyMfaLogin(req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } }); - setAuthCookie(res, result.token!, req); - res.json({ token: result.token, user: result.user }); -}); - -router.post('/mfa/setup', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = setupMfa(authReq.user.id, authReq.user.email); - if (result.error) return res.status(result.status!).json({ error: result.error }); - result.qrPromise! - .then((qr_svg: string) => { - res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg }); - }) - .catch((err: unknown) => { - console.error('[MFA] QR code generation error:', err); - res.status(500).json({ error: 'Could not generate QR code' }); - }); -}); - -router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = enableMfa(authReq.user.id, req.body.code); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) }); - res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes }); -}); - -router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = disableMfa(authReq.user.id, authReq.user.email, req.body); - if (result.error) return res.status(result.status!).json({ error: result.error }); - writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) }); - res.json({ success: true, mfa_enabled: result.mfa_enabled }); -}); - -// --- MCP Token Management --- - -router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ tokens: listMcpTokens(authReq.user.id) }); -}); - -router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = createMcpToken(authReq.user.id, req.body.name); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.status(201).json({ token: result.token }); -}); - -router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = deleteMcpToken(authReq.user.id, req.params.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -// Short-lived single-use token for WebSocket connections -router.post('/ws-token', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = createWsToken(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ token: result.token }); -}); - -// Short-lived single-use token for direct resource URLs -router.post('/resource-token', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const token = createResourceToken(authReq.user.id, req.body.purpose); - if (!token) return res.status(503).json({ error: 'Service unavailable' }); - res.json(token); -}); - -export default router; - -// Exported for test resets only — do not use in production code -export { loginAttempts, mfaAttempts }; diff --git a/server/src/routes/backup.ts b/server/src/routes/backup.ts deleted file mode 100644 index 58e5995a..00000000 --- a/server/src/routes/backup.ts +++ /dev/null @@ -1,197 +0,0 @@ -import express, { Request, Response, NextFunction } from 'express'; -import multer from 'multer'; -import fs from 'fs'; -import { authenticate, adminOnly } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { writeAudit, getClientIp } from '../services/auditLog'; -import { - listBackups, - createBackup, - restoreFromZip, - getAutoSettings, - updateAutoSettings, - deleteBackup, - isValidBackupFilename, - backupFilePath, - backupFileExists, - checkRateLimit, - getUploadTmpDir, - BACKUP_RATE_WINDOW, - MAX_BACKUP_UPLOAD_SIZE, -} from '../services/backupService'; - -const router = express.Router(); - -router.use(authenticate, adminOnly); - -// --------------------------------------------------------------------------- -// Rate-limiter middleware (HTTP concern wrapping service-level check) -// --------------------------------------------------------------------------- - -function backupRateLimiter(maxAttempts: number, windowMs: number) { - return (req: Request, res: Response, next: NextFunction) => { - const key = req.ip || 'unknown'; - if (!checkRateLimit(key, maxAttempts, windowMs)) { - return res.status(429).json({ error: 'Too many backup requests. Please try again later.' }); - } - next(); - }; -} - -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- - -router.get('/list', (_req: Request, res: Response) => { - try { - res.json({ backups: listBackups() }); - } catch (err: unknown) { - res.status(500).json({ error: 'Error loading backups' }); - } -}); - -router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (req: Request, res: Response) => { - try { - const backup = await createBackup(); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'backup.create', - resource: backup.filename, - ip: getClientIp(req), - details: { size: backup.size }, - }); - res.json({ success: true, backup }); - } catch (err: unknown) { - res.status(500).json({ error: 'Error creating backup' }); - } -}); - -router.get('/download/:filename', (req: Request, res: Response) => { - const { filename } = req.params; - - if (!isValidBackupFilename(filename)) { - return res.status(400).json({ error: 'Invalid filename' }); - } - if (!backupFileExists(filename)) { - return res.status(404).json({ error: 'Backup not found' }); - } - - res.download(backupFilePath(filename), filename); -}); - -router.post('/restore/:filename', async (req: Request, res: Response) => { - const { filename } = req.params; - if (!isValidBackupFilename(filename)) { - return res.status(400).json({ error: 'Invalid filename' }); - } - const zipPath = backupFilePath(filename); - if (!backupFileExists(filename)) { - return res.status(404).json({ error: 'Backup not found' }); - } - - try { - const result = await restoreFromZip(zipPath); - if (!result.success) { - return res.status(result.status || 400).json({ error: result.error }); - } - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'backup.restore', - resource: filename, - ip: getClientIp(req), - }); - res.json({ success: true }); - } catch (err: unknown) { - if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' }); - } -}); - -const uploadTmp = multer({ - dest: getUploadTmpDir(), - fileFilter: (_req, file, cb) => { - if (file.originalname.endsWith('.zip')) cb(null, true); - else cb(new Error('Only ZIP files allowed')); - }, - limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE }, -}); - -router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => { - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const zipPath = req.file.path; - const origName = req.file.originalname || 'upload.zip'; - - try { - const result = await restoreFromZip(zipPath); - if (!result.success) { - return res.status(result.status || 400).json({ error: result.error }); - } - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'backup.upload_restore', - resource: origName, - ip: getClientIp(req), - }); - res.json({ success: true }); - } catch (err: unknown) { - if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' }); - } finally { - if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath); - } -}); - -router.get('/auto-settings', (_req: Request, res: Response) => { - try { - const data = getAutoSettings(); - res.json(data); - } catch (err: unknown) { - console.error('[backup] GET auto-settings:', err); - res.status(500).json({ error: 'Could not load backup settings' }); - } -}); - -router.put('/auto-settings', (req: Request, res: Response) => { - try { - const settings = updateAutoSettings((req.body || {}) as Record); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'backup.auto_settings', - ip: getClientIp(req), - details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days }, - }); - res.json({ settings }); - } catch (err: unknown) { - console.error('[backup] PUT auto-settings:', err); - const msg = err instanceof Error ? err.message : String(err); - res.status(500).json({ - error: 'Could not save auto-backup settings', - detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined, - }); - } -}); - -router.delete('/:filename', (req: Request, res: Response) => { - const { filename } = req.params; - - if (!isValidBackupFilename(filename)) { - return res.status(400).json({ error: 'Invalid filename' }); - } - if (!backupFileExists(filename)) { - return res.status(404).json({ error: 'Backup not found' }); - } - - deleteBackup(filename); - const authReq = req as AuthRequest; - writeAudit({ - userId: authReq.user.id, - action: 'backup.delete', - resource: filename, - ip: getClientIp(req), - }); - res.json({ success: true }); -}); - -export default router; diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts deleted file mode 100644 index b0ec405b..00000000 --- a/server/src/routes/budget.ts +++ /dev/null @@ -1,189 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { db } from '../db/database'; -import { - verifyTripAccess, - listBudgetItems, - createBudgetItem, - updateBudgetItem, - deleteBudgetItem, - updateMembers, - toggleMemberPaid, - getPerPersonSummary, - calculateSettlement, - reorderBudgetItems, - reorderBudgetCategories, -} from '../services/budgetService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - res.json({ items: listBudgetItems(tripId) }); -}); - -router.get('/summary/per-person', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - if (!verifyTripAccess(Number(tripId), authReq.user.id)) - return res.status(404).json({ error: 'Trip not found' }); - - res.json({ summary: getPerPersonSummary(tripId) }); -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { name } = req.body; - if (!name) return res.status(400).json({ error: 'Name is required' }); - - const item = createBudgetItem(tripId, req.body); - res.status(201).json({ item }); - broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id'] as string); -}); - -router.put('/reorder/items', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { orderedIds } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - reorderBudgetItems(tripId, orderedIds); - res.json({ success: true }); - broadcast(tripId, 'budget:reordered', { orderedIds }, req.headers['x-socket-id'] as string); -}); - -router.put('/reorder/categories', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { orderedCategories } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - reorderBudgetCategories(tripId, orderedCategories); - res.json({ success: true }); - broadcast(tripId, 'budget:reordered', { orderedCategories }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const updated = updateBudgetItem(id, tripId, req.body); - if (!updated) return res.status(404).json({ error: 'Budget item not found' }); - - // Sync price back to linked reservation - if (updated.reservation_id && req.body.total_price !== undefined) { - try { - const reservation = db.prepare('SELECT id, metadata FROM reservations WHERE id = ? AND trip_id = ?').get(updated.reservation_id, tripId) as { id: number; metadata: string | null } | undefined; - if (reservation) { - const meta = reservation.metadata ? JSON.parse(reservation.metadata) : {}; - meta.price = String(updated.total_price); - db.prepare('UPDATE reservations SET metadata = ? WHERE id = ?').run(JSON.stringify(meta), reservation.id); - const updatedRes = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservation.id); - broadcast(tripId, 'reservation:updated', { reservation: updatedRes }, req.headers['x-socket-id'] as string); - } - } catch (err) { - console.error('[budget] Failed to sync price to reservation:', err); - } - } - - res.json({ item: updated }); - broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id/members', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const access = verifyTripAccess(Number(tripId), authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { user_ids } = req.body; - if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' }); - - const result = updateMembers(id, tripId, user_ids); - if (!result) return res.status(404).json({ error: 'Budget item not found' }); - - res.json({ members: result.members, item: result.item }); - broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members: result.members, persons: result.item.persons }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id, userId } = req.params; - - const access = verifyTripAccess(Number(tripId), authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { paid } = req.body; - const member = toggleMemberPaid(id, userId, paid); - res.json({ member }); - broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string); -}); - -router.get('/settlement', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - if (!verifyTripAccess(Number(tripId), authReq.user.id)) - return res.status(404).json({ error: 'Trip not found' }); - - res.json(calculateSettlement(tripId)); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('budget_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deleteBudgetItem(id, tripId)) - return res.status(404).json({ error: 'Budget item not found' }); - - res.json({ success: true }); - broadcast(tripId, 'budget:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -export default router; diff --git a/server/src/routes/categories.ts b/server/src/routes/categories.ts deleted file mode 100644 index b33712e7..00000000 --- a/server/src/routes/categories.ts +++ /dev/null @@ -1,35 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate, adminOnly } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import * as categoryService from '../services/categoryService'; - -const router = express.Router(); - -router.get('/', authenticate, (_req: Request, res: Response) => { - res.json({ categories: categoryService.listCategories() }); -}); - -router.post('/', authenticate, adminOnly, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { name, color, icon } = req.body; - if (!name) return res.status(400).json({ error: 'Category name is required' }); - const category = categoryService.createCategory(authReq.user.id, name, color, icon); - res.status(201).json({ category }); -}); - -router.put('/:id', authenticate, adminOnly, (req: Request, res: Response) => { - const { name, color, icon } = req.body; - if (!categoryService.getCategoryById(req.params.id)) - return res.status(404).json({ error: 'Category not found' }); - const category = categoryService.updateCategory(req.params.id, name, color, icon); - res.json({ category }); -}); - -router.delete('/:id', authenticate, adminOnly, (req: Request, res: Response) => { - if (!categoryService.getCategoryById(req.params.id)) - return res.status(404).json({ error: 'Category not found' }); - categoryService.deleteCategory(req.params.id); - res.json({ success: true }); -}); - -export default router; diff --git a/server/src/routes/collab.ts b/server/src/routes/collab.ts deleted file mode 100644 index fc2b97ff..00000000 --- a/server/src/routes/collab.ts +++ /dev/null @@ -1,328 +0,0 @@ -import express, { Request, Response } from 'express'; -import multer from 'multer'; -import path from 'path'; -import fs from 'fs'; -import { v4 as uuidv4 } from 'uuid'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { validateStringLengths } from '../middleware/validate'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { db } from '../db/database'; -import { BLOCKED_EXTENSIONS } from '../services/fileService'; -import { - verifyTripAccess, - listNotes, - createNote, - updateNote, - deleteNote, - addNoteFile, - getFormattedNoteById, - deleteNoteFile, - listPolls, - createPoll, - votePoll, - closePoll, - deletePoll, - listMessages, - createMessage, - deleteMessage, - addOrRemoveReaction, - fetchLinkPreview, -} from '../services/collabService'; - -const MAX_NOTE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB -const filesDir = path.join(__dirname, '../../uploads/files'); -const noteUpload = multer({ - storage: multer.diskStorage({ - destination: (_req, _file, cb) => { if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); cb(null, filesDir) }, - filename: (_req, file, cb) => { cb(null, `${uuidv4()}${path.extname(file.originalname)}`) }, - }), - limits: { fileSize: MAX_NOTE_FILE_SIZE }, - defParamCharset: 'utf8', - fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - // Share the single BLOCKED_EXTENSIONS list from fileService so - // executable/script attachments can't sneak in via collab when the - // main uploader already rejects them. - if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg') || file.mimetype.includes('html') || file.mimetype.includes('javascript')) { - const err: Error & { statusCode?: number } = new Error('File type not allowed'); - err.statusCode = 400; - return cb(err); - } - cb(null, true); - }, -}); - -const router = express.Router({ mergeParams: true }); - -/* ------------------------------------------------------------------ */ -/* Notes */ -/* ------------------------------------------------------------------ */ - -router.get('/notes', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - - res.json({ notes: listNotes(tripId) }); -}); - -router.post('/notes', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { title, content, category, color, website } = req.body; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!title) return res.status(400).json({ error: 'Title is required' }); - - const formatted = createNote(tripId, authReq.user.id, { title, content, category, color, website }); - res.status(201).json({ note: formatted }); - broadcast(tripId, 'collab:note:created', { note: formatted }, req.headers['x-socket-id'] as string); - - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, tripId: String(tripId) } }).catch(() => {}); - }); -}); - -router.put('/notes/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { title, content, category, color, pinned, website } = req.body; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const formatted = updateNote(tripId, id, { title, content, category, color, pinned, website }); - if (!formatted) return res.status(404).json({ error: 'Note not found' }); - - res.json({ note: formatted }); - broadcast(tripId, 'collab:note:updated', { note: formatted }, req.headers['x-socket-id'] as string); -}); - -router.delete('/notes/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deleteNote(tripId, id)) return res.status(404).json({ error: 'Note not found' }); - - res.json({ success: true }); - broadcast(tripId, 'collab:note:deleted', { noteId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -/* ------------------------------------------------------------------ */ -/* Note files */ -/* ------------------------------------------------------------------ */ - -router.post('/notes/:id/files', authenticate, noteUpload.single('file'), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const access = verifyTripAccess(Number(tripId), authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_upload', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission to upload files' }); - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - - const result = addNoteFile(tripId, id, req.file); - if (!result) return res.status(404).json({ error: 'Note not found' }); - - res.status(201).json(result); - broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string); -}); - -router.delete('/notes/:id/files/:fileId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id, fileId } = req.params; - const access = verifyTripAccess(Number(tripId), authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deleteNoteFile(id, fileId)) return res.status(404).json({ error: 'File not found' }); - - res.json({ success: true }); - broadcast(Number(tripId), 'collab:note:updated', { note: getFormattedNoteById(id) }, req.headers['x-socket-id'] as string); -}); - -/* ------------------------------------------------------------------ */ -/* Polls */ -/* ------------------------------------------------------------------ */ - -router.get('/polls', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - - res.json({ polls: listPolls(tripId) }); -}); - -router.post('/polls', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { question, options, multiple, multiple_choice, deadline } = req.body; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!question) return res.status(400).json({ error: 'Question is required' }); - if (!Array.isArray(options) || options.length < 2) { - return res.status(400).json({ error: 'At least 2 options are required' }); - } - - const poll = createPoll(tripId, authReq.user.id, { question, options, multiple, multiple_choice, deadline }); - res.status(201).json({ poll }); - broadcast(tripId, 'collab:poll:created', { poll }, req.headers['x-socket-id'] as string); -}); - -router.post('/polls/:id/vote', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { option_index } = req.body; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const result = votePoll(tripId, id, authReq.user.id, option_index); - if (result.error === 'not_found') return res.status(404).json({ error: 'Poll not found' }); - if (result.error === 'closed') return res.status(400).json({ error: 'Poll is closed' }); - if (result.error === 'invalid_index') return res.status(400).json({ error: 'Invalid option index' }); - - res.json({ poll: result.poll }); - broadcast(tripId, 'collab:poll:voted', { poll: result.poll }, req.headers['x-socket-id'] as string); -}); - -router.put('/polls/:id/close', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const updatedPoll = closePoll(tripId, id); - if (!updatedPoll) return res.status(404).json({ error: 'Poll not found' }); - - res.json({ poll: updatedPoll }); - broadcast(tripId, 'collab:poll:closed', { poll: updatedPoll }, req.headers['x-socket-id'] as string); -}); - -router.delete('/polls/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deletePoll(tripId, id)) return res.status(404).json({ error: 'Poll not found' }); - - res.json({ success: true }); - broadcast(tripId, 'collab:poll:deleted', { pollId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -/* ------------------------------------------------------------------ */ -/* Messages */ -/* ------------------------------------------------------------------ */ - -router.get('/messages', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { before } = req.query; - if (!verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - - res.json({ messages: listMessages(tripId, before as string | undefined) }); -}); - -router.post('/messages', authenticate, validateStringLengths({ text: 5000 }), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { text, reply_to } = req.body; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!text || !text.trim()) return res.status(400).json({ error: 'Message text is required' }); - - const result = createMessage(tripId, authReq.user.id, text, reply_to); - if (result.error === 'reply_not_found') return res.status(400).json({ error: 'Reply target message not found' }); - - res.status(201).json({ message: result.message }); - broadcast(tripId, 'collab:message:created', { message: result.message }, req.headers['x-socket-id'] as string); - - // Notify trip members about new chat message - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - const preview = text.trim().length > 80 ? text.trim().substring(0, 80) + '...' : text.trim(); - send({ event: 'collab_message', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, preview, tripId: String(tripId) } }).catch(() => {}); - }); -}); - -/* ------------------------------------------------------------------ */ -/* Reactions */ -/* ------------------------------------------------------------------ */ - -router.post('/messages/:id/react', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { emoji } = req.body; - const access = verifyTripAccess(Number(tripId), authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!emoji) return res.status(400).json({ error: 'Emoji is required' }); - - const result = addOrRemoveReaction(id, tripId, authReq.user.id, emoji); - if (!result.found) return res.status(404).json({ error: 'Message not found' }); - - res.json({ reactions: result.reactions }); - broadcast(Number(tripId), 'collab:message:reacted', { messageId: Number(id), reactions: result.reactions }, req.headers['x-socket-id'] as string); -}); - -/* ------------------------------------------------------------------ */ -/* Delete message */ -/* ------------------------------------------------------------------ */ - -router.delete('/messages/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('collab_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const result = deleteMessage(tripId, id, authReq.user.id); - if (result.error === 'not_found') return res.status(404).json({ error: 'Message not found' }); - if (result.error === 'not_owner') return res.status(403).json({ error: 'You can only delete your own messages' }); - - res.json({ success: true }); - broadcast(tripId, 'collab:message:deleted', { messageId: Number(id), username: result.username || authReq.user.username }, req.headers['x-socket-id'] as string); -}); - -/* ------------------------------------------------------------------ */ -/* Link preview */ -/* ------------------------------------------------------------------ */ - -router.get('/link-preview', authenticate, async (req: Request, res: Response) => { - const { url } = req.query as { url?: string }; - if (!url) return res.status(400).json({ error: 'URL is required' }); - - try { - const preview = await fetchLinkPreview(url); - const asAny = preview as any; - if (asAny.error) return res.status(400).json({ error: asAny.error }); - res.json(preview); - } catch { - res.json({ title: null, description: null, image: null, url }); - } -}); - -export default router; diff --git a/server/src/routes/dayNotes.ts b/server/src/routes/dayNotes.ts deleted file mode 100644 index 48c46ff2..00000000 --- a/server/src/routes/dayNotes.ts +++ /dev/null @@ -1,66 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { validateStringLengths } from '../middleware/validate'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import * as dayNoteService from '../services/dayNoteService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, dayId } = req.params; - if (!dayNoteService.verifyTripAccess(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - res.json({ notes: dayNoteService.listNotes(dayId, tripId) }); -}); - -router.post('/', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, dayId } = req.params; - const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!dayNoteService.dayExists(dayId, tripId)) return res.status(404).json({ error: 'Day not found' }); - - const { text, time, icon, sort_order } = req.body; - if (!text?.trim()) return res.status(400).json({ error: 'Text required' }); - - const note = dayNoteService.createNote(dayId, tripId, text, time, icon, sort_order); - res.status(201).json({ note }); - broadcast(tripId, 'dayNote:created', { dayId: Number(dayId), note }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id', authenticate, validateStringLengths({ text: 500, time: 150 }), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, dayId, id } = req.params; - const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const current = dayNoteService.getNote(id, dayId, tripId); - if (!current) return res.status(404).json({ error: 'Note not found' }); - - const { text, time, icon, sort_order } = req.body; - const updated = dayNoteService.updateNote(id, current, { text, time, icon, sort_order }); - res.json({ note: updated }); - broadcast(tripId, 'dayNote:updated', { dayId: Number(dayId), note: updated }, req.headers['x-socket-id'] as string); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, dayId, id } = req.params; - const access = dayNoteService.verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('day_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!dayNoteService.getNote(id, dayId, tripId)) return res.status(404).json({ error: 'Note not found' }); - dayNoteService.deleteNote(id); - res.json({ success: true }); - broadcast(tripId, 'dayNote:deleted', { noteId: Number(id), dayId: Number(dayId) }, req.headers['x-socket-id'] as string); -}); - -export default router; diff --git a/server/src/routes/days.ts b/server/src/routes/days.ts deleted file mode 100644 index 659b7096..00000000 --- a/server/src/routes/days.ts +++ /dev/null @@ -1,133 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { requireTripAccess } from '../middleware/tripAccess'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import * as dayService from '../services/dayService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params; - res.json(dayService.listDays(tripId)); -}); - -router.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const { date, notes } = req.body; - - const day = dayService.createDay(tripId, date, notes); - res.status(201).json({ day }); - broadcast(tripId, 'day:created', { day }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - const current = dayService.getDay(id, tripId); - if (!current) return res.status(404).json({ error: 'Day not found' }); - - const { notes, title } = req.body; - const day = dayService.updateDay(id, current, { notes, title }); - res.json({ day }); - broadcast(tripId, 'day:updated', { day }, req.headers['x-socket-id'] as string); -}); - -router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - if (!dayService.getDay(id, tripId)) return res.status(404).json({ error: 'Day not found' }); - - dayService.deleteDay(id); - res.json({ success: true }); - broadcast(tripId, 'day:deleted', { dayId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -// --------------------------------------------------------------------------- -// Accommodations sub-router -// --------------------------------------------------------------------------- - -const accommodationsRouter = express.Router({ mergeParams: true }); - -accommodationsRouter.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params; - res.json({ accommodations: dayService.listAccommodations(tripId) }); -}); - -accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body; - - if (!place_id || !start_day_id || !end_day_id) { - return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' }); - } - - const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); - if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - - const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); - res.status(201).json({ accommodation }); - broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string); - broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string); -}); - -accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - const existing = dayService.getAccommodation(id, tripId); - if (!existing) return res.status(404).json({ error: 'Accommodation not found' }); - - const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body; - - const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); - if (errors.length > 0) return res.status(404).json({ error: errors[0].message }); - - const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes }); - res.json({ accommodation }); - broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string); -}); - -accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('day_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - if (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' }); - - const { linkedReservationId, deletedBudgetItemId } = dayService.deleteAccommodation(id); - if (linkedReservationId) { - broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string); - } - if (deletedBudgetItemId) { - broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string); - } - - res.json({ success: true }); - broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -export default router; -export { accommodationsRouter }; diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts deleted file mode 100644 index 65e3d250..00000000 --- a/server/src/routes/files.ts +++ /dev/null @@ -1,284 +0,0 @@ -import express, { Request, Response } from 'express'; -import multer from 'multer'; -import path from 'path'; -import fs from 'fs'; -import { v4 as uuidv4 } from 'uuid'; -import { authenticate, demoUploadBlock } from '../middleware/auth'; -import { requireTripAccess } from '../middleware/tripAccess'; -import { broadcast } from '../websocket'; -import { AuthRequest } from '../types'; -import { checkPermission } from '../services/permissions'; -import { - MAX_FILE_SIZE, - BLOCKED_EXTENSIONS, - filesDir, - getAllowedExtensions, - verifyTripAccess, - formatFile, - resolveFilePath, - authenticateDownload, - listFiles, - getFileById, - getFileByIdFull, - getDeletedFile, - createFile, - updateFile, - toggleStarred, - softDeleteFile, - restoreFile, - permanentDeleteFile, - emptyTrash, - createFileLink, - deleteFileLink, - getFileLinks, -} from '../services/fileService'; - -const router = express.Router({ mergeParams: true }); - -// --------------------------------------------------------------------------- -// Multer setup (HTTP middleware — stays in route) -// --------------------------------------------------------------------------- - -const storage = multer.diskStorage({ - destination: (_req, _file, cb) => { - if (!fs.existsSync(filesDir)) fs.mkdirSync(filesDir, { recursive: true }); - cb(null, filesDir); - }, - filename: (_req, file, cb) => { - const ext = path.extname(file.originalname); - cb(null, `${uuidv4()}${ext}`); - }, -}); - -const upload = multer({ - storage, - limits: { fileSize: MAX_FILE_SIZE }, - defParamCharset: 'utf8', - fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - if (BLOCKED_EXTENSIONS.includes(ext) || file.mimetype.includes('svg')) { - const err: Error & { statusCode?: number } = new Error('File type not allowed'); - err.statusCode = 400; - return cb(err); - } - const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase()); - const fileExt = ext.replace('.', ''); - if (allowed.includes(fileExt) || (allowed.includes('*') && !BLOCKED_EXTENSIONS.includes(ext))) { - cb(null, true); - } else { - const err: Error & { statusCode?: number } = new Error('File type not allowed'); - err.statusCode = 400; - cb(err); - } - }, -}); - -// --------------------------------------------------------------------------- -// Routes -// --------------------------------------------------------------------------- - -// Authenticated file download (supports cookie, Bearer header, or ?token= query param) -router.get('/:id/download', (req: Request, res: Response) => { - const { tripId, id } = req.params; - - const auth = authenticateDownload(req); - if ('error' in auth) return res.status(auth.status).json({ error: auth.error }); - - const trip = verifyTripAccess(tripId, auth.userId); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const file = getFileById(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found' }); - - const { resolved, safe } = resolveFilePath(file.filename); - if (!safe) return res.status(403).json({ error: 'Forbidden' }); - if (!fs.existsSync(resolved)) return res.status(404).json({ error: 'File not found' }); - - // Serve Apple Wallet passes inline with the canonical MIME type so Safari - // (iOS/macOS) hands them off to Wallet instead of downloading as a blob. - if (path.extname(resolved).toLowerCase() === '.pkpass') { - res.setHeader('Content-Type', 'application/vnd.apple.pkpass'); - res.setHeader('Content-Disposition', `inline; filename="${path.basename(file.original_name || resolved)}"`); - } - - res.sendFile(resolved); -}); - -// List files (excludes soft-deleted by default) -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const showTrash = req.query.trash === 'true'; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - res.json({ files: listFiles(tripId, showTrash) }); -}); - -// Upload file -router.post('/', authenticate, requireTripAccess, demoUploadBlock, upload.single('file'), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { user_id: tripOwnerId } = authReq.trip!; - if (!checkPermission('file_upload', authReq.user.role, tripOwnerId, authReq.user.id, tripOwnerId !== authReq.user.id)) - return res.status(403).json({ error: 'No permission to upload files' }); - - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - - const { place_id, description, reservation_id } = req.body; - const created = createFile(tripId, req.file, authReq.user.id, { place_id, description, reservation_id }); - res.status(201).json({ file: created }); - broadcast(tripId, 'file:created', { file: created }, req.headers['x-socket-id'] as string); -}); - -// Update file metadata -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { description, place_id, reservation_id } = req.body; - - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_edit', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission to edit files' }); - - const file = getFileById(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found' }); - - const updated = updateFile(id, file, { description, place_id, reservation_id }); - res.json({ file: updated }); - broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string); -}); - -// Toggle starred -router.patch('/:id/star', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const file = getFileById(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found' }); - - const updated = toggleStarred(id, file.starred); - res.json({ file: updated }); - broadcast(tripId, 'file:updated', { file: updated }, req.headers['x-socket-id'] as string); -}); - -// Soft-delete (move to trash) -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const access = verifyTripAccess(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_delete', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission to delete files' }); - - const file = getFileById(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found' }); - - softDeleteFile(id); - res.json({ success: true }); - broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -// Restore from trash -router.post('/:id/restore', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const file = getDeletedFile(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found in trash' }); - - const restored = restoreFile(id); - res.json({ file: restored }); - broadcast(tripId, 'file:created', { file: restored }, req.headers['x-socket-id'] as string); -}); - -// Permanently delete from trash -router.delete('/:id/permanent', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const file = getDeletedFile(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found in trash' }); - - await permanentDeleteFile(file); - res.json({ success: true }); - broadcast(tripId, 'file:deleted', { fileId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -// Empty entire trash -router.delete('/trash/empty', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_delete', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const deleted = await emptyTrash(tripId); - res.json({ success: true, deleted }); -}); - -// Link a file to a reservation (many-to-many) -router.post('/:id/link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { reservation_id, assignment_id, place_id } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const file = getFileById(id, tripId); - if (!file) return res.status(404).json({ error: 'File not found' }); - - const links = createFileLink(id, { reservation_id, assignment_id, place_id }); - res.json({ success: true, links }); -}); - -// Unlink a file from a reservation -router.delete('/:id/link/:linkId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id, linkId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('file_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - deleteFileLink(linkId, id); - res.json({ success: true }); -}); - -// Get all links for a file -router.get('/:id/links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const links = getFileLinks(id); - res.json({ links }); -}); - -export default router; diff --git a/server/src/routes/journey.ts b/server/src/routes/journey.ts deleted file mode 100644 index b655d7ce..00000000 --- a/server/src/routes/journey.ts +++ /dev/null @@ -1,393 +0,0 @@ -import express, { Request, Response } from 'express'; -import multer from 'multer'; -import path from 'node:path'; -import fs from 'node:fs'; -import crypto from 'node:crypto'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import * as svc from '../services/journeyService'; -import { db } from '../db/database'; -import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService'; -import { uploadToImmich } from '../services/memories/immichService'; -import { getAllowedExtensions } from '../services/fileService'; - -const router = express.Router(); - -const uploadsBase = path.join(__dirname, '../../uploads/journey'); - -const storage = multer.diskStorage({ - destination: (_req, _file, cb) => { - if (!fs.existsSync(uploadsBase)) fs.mkdirSync(uploadsBase, { recursive: true }); - cb(null, uploadsBase); - }, - filename: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase() || '.jpg'; - cb(null, `${crypto.randomUUID()}${ext}`); - }, -}); - -const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => { - if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) { - const err: Error & { statusCode?: number } = new Error('Only image files are allowed'); - err.statusCode = 400; - return cb(err); - } - const ext = path.extname(file.originalname).toLowerCase().replace('.', ''); - const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase()); - if (!allowed.includes('*') && !allowed.includes(ext)) { - const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`); - err.statusCode = 400; - return cb(err); - } - cb(null, true); -}; - -const upload = multer({ - storage, - limits: { fileSize: 20 * 1024 * 1024 }, - fileFilter: imageFilter, -}); - -// ── Static prefix routes (MUST come before /:id) ───────────────────────── - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ journeys: svc.listJourneys(authReq.user.id) }); -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { title, subtitle, trip_ids } = req.body || {}; - if (!title || typeof title !== 'string' || !title.trim()) { - return res.status(400).json({ error: 'Title is required' }); - } - const journey = svc.createJourney(authReq.user.id, { - title: title.trim(), - subtitle, - trip_ids: Array.isArray(trip_ids) ? trip_ids : [], - }); - res.status(201).json(journey); -}); - -router.get('/suggestions', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ trips: svc.getSuggestions(authReq.user.id) }); -}); - -router.get('/available-trips', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ trips: svc.listUserTrips(authReq.user.id) }); -}); - -// ── Entries (prefix /entries — before /:id) ────────────────────────────── - -router.patch('/entries/:entryId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.updateEntry(Number(req.params.entryId), authReq.user.id, req.body || {}, req.headers['x-socket-id'] as string); - if (!result) return res.status(404).json({ error: 'Entry not found' }); - res.json(result); -}); - -router.delete('/entries/:entryId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!svc.deleteEntry(Number(req.params.entryId), authReq.user.id, req.headers['x-socket-id'] as string)) { - return res.status(404).json({ error: 'Entry not found' }); - } - res.json({ success: true }); -}); - -// ── Photos (prefix /photos and /entries — before /:id) ─────────────────── - -router.post('/entries/:entryId/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const files = req.files as Express.Multer.File[]; - if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); - - const results: any[] = []; - for (const file of files) { - const relativePath = `journey/${file.filename}`; - const photo = svc.addPhoto( - Number(req.params.entryId), - authReq.user.id, - relativePath, - undefined, - req.body?.caption - ); - if (photo) { - // Mirror to Immich only when the user has explicitly opted in via the - // Immich integration settings. Avoids the "surprise upload" in #730 - // where a write-capable API key implicitly enabled mirroring. - const prefs = db.prepare('SELECT immich_auto_upload FROM users WHERE id = ?').get(authReq.user.id) as { immich_auto_upload?: number } | undefined; - if (prefs?.immich_auto_upload) { - try { - const immichId = await uploadToImmich(authReq.user.id, relativePath, file.originalname); - if (immichId) { - // photo.id is now the gallery photo id (journey_photos.id) - svc.setPhotoProvider(photo.id, 'immich', immichId, authReq.user.id); - (photo as any).provider = 'immich'; - (photo as any).asset_id = immichId; - (photo as any).owner_id = authReq.user.id; - } - } catch {} - } - results.push(photo); - } - } - - if (!results.length) return res.status(403).json({ error: 'Not allowed' }); - res.status(201).json({ photos: results }); -}); - -router.post('/entries/:entryId/provider-photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { provider, asset_id, asset_ids, caption, passphrase } = req.body || {}; - const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined; - - // Batch mode: { provider, asset_ids: string[] } - if (Array.isArray(asset_ids) && provider) { - const added: any[] = []; - for (const id of asset_ids) { - const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, String(id), caption, pp); - if (photo) added.push(photo); - } - return res.status(201).json({ photos: added, added: added.length }); - } - - // Single mode (backward compat) - if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' }); - const photo = svc.addProviderPhoto(Number(req.params.entryId), authReq.user.id, provider, asset_id, caption, pp); - if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' }); - res.status(201).json(photo); -}); - -// Link a gallery photo to an entry -router.post('/entries/:entryId/link-photo', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - // Accept journey_photo_id (new) or photo_id (legacy alias) for backwards compat - const journeyPhotoId = (req.body || {}).journey_photo_id ?? (req.body || {}).photo_id; - if (!journeyPhotoId) return res.status(400).json({ error: 'journey_photo_id required' }); - const result = svc.linkPhotoToEntry(Number(req.params.entryId), Number(journeyPhotoId), authReq.user.id); - if (!result) return res.status(403).json({ error: 'Not allowed' }); - res.status(201).json(result); -}); - -// Unlink a photo from a specific entry (gallery row is preserved) -router.delete('/entries/:entryId/photos/:journeyPhotoId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const ok = svc.unlinkPhotoFromEntry(Number(req.params.entryId), Number(req.params.journeyPhotoId), authReq.user.id); - if (!ok) return res.status(404).json({ error: 'Not found or not allowed' }); - res.status(204).end(); -}); - -router.patch('/photos/:photoId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.updatePhoto(Number(req.params.photoId), authReq.user.id, req.body || {}); - if (!result) return res.status(404).json({ error: 'Photo not found' }); - res.json(result); -}); - -// Hard-delete: removes gallery row + cascades to all entry links + deletes file if unreferenced -router.delete('/photos/:photoId', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const photo = svc.deletePhoto(Number(req.params.photoId), authReq.user.id); - if (!photo) return res.status(404).json({ error: 'Photo not found' }); - if (photo.file_path) { - const fullPath = path.join(__dirname, '../../uploads', photo.file_path); - try { fs.unlinkSync(fullPath); } catch {} - } - res.json({ success: true }); -}); - -// ── Gallery (prefix /:id/gallery — before /:id) ────────────────────────── - -// Upload photos directly to the journey gallery (no entry association) -router.post('/:id/gallery/photos', authenticate, upload.array('photos'), async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const files = req.files as Express.Multer.File[]; - if (!files?.length) return res.status(400).json({ error: 'No files uploaded' }); - - const filePaths = files.map(f => ({ path: `journey/${f.filename}` })); - const photos = svc.uploadGalleryPhotos(Number(req.params.id), authReq.user.id, filePaths); - if (!photos.length) return res.status(403).json({ error: 'Not allowed' }); - res.status(201).json({ photos }); -}); - -// Add provider photos to gallery only (no entry link) -router.post('/:id/gallery/provider-photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { provider, asset_id, asset_ids, passphrase } = req.body || {}; - const pp = passphrase && typeof passphrase === 'string' ? passphrase : undefined; - - if (Array.isArray(asset_ids) && provider) { - const added: any[] = []; - for (const id of asset_ids) { - const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, String(id), undefined, pp); - if (photo) added.push(photo); - } - return res.status(201).json({ photos: added, added: added.length }); - } - - if (!provider || !asset_id) return res.status(400).json({ error: 'provider and asset_id required' }); - const photo = svc.addProviderPhotoToGallery(Number(req.params.id), authReq.user.id, provider, asset_id, undefined, pp); - if (!photo) return res.status(403).json({ error: 'Not allowed or duplicate' }); - res.status(201).json(photo); -}); - -// Hard-delete a gallery photo (removes from all entries) -router.delete('/:id/gallery/:journeyPhotoId', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const photo = svc.deleteGalleryPhoto(Number(req.params.journeyPhotoId), authReq.user.id); - if (!photo) return res.status(404).json({ error: 'Photo not found or not allowed' }); - if (photo.file_path) { - const fullPath = path.join(__dirname, '../../uploads', photo.file_path); - try { fs.unlinkSync(fullPath); } catch {} - } - res.status(204).end(); -}); - -// ── Journeys /:id (parameterized routes AFTER static prefixes) ─────────── - -router.get('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const data = svc.getJourneyFull(Number(req.params.id), authReq.user.id); - if (!data) return res.status(404).json({ error: 'Journey not found' }); - res.json(data); -}); - -router.patch('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.updateJourney(Number(req.params.id), authReq.user.id, req.body || {}); - if (!result) return res.status(404).json({ error: 'Journey not found' }); - res.json(result); -}); - -router.post('/:id/cover', authenticate, upload.single('cover'), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!req.file) return res.status(400).json({ error: 'No file uploaded' }); - const relativePath = `journey/${req.file.filename}`; - const result = svc.updateJourney(Number(req.params.id), authReq.user.id, { cover_image: relativePath }); - if (!result) return res.status(404).json({ error: 'Journey not found' }); - res.json(result); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!svc.deleteJourney(Number(req.params.id), authReq.user.id)) { - return res.status(404).json({ error: 'Journey not found' }); - } - res.json({ success: true }); -}); - -// ── Journey trips ──────────────────────────────────────────────────────── - -router.post('/:id/trips', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { trip_id } = req.body || {}; - if (!trip_id) return res.status(400).json({ error: 'trip_id required' }); - if (!svc.addTripToJourney(Number(req.params.id), trip_id, authReq.user.id)) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -router.delete('/:id/trips/:tripId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!svc.removeTripFromJourney(Number(req.params.id), Number(req.params.tripId), authReq.user.id)) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -// ── Entries under journey ──────────────────────────────────────────────── - -router.get('/:id/entries', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const entries = svc.listEntries(Number(req.params.id), authReq.user.id); - if (!entries) return res.status(404).json({ error: 'Journey not found' }); - res.json({ entries }); -}); - -router.post('/:id/entries', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { entry_date } = req.body || {}; - if (!entry_date) return res.status(400).json({ error: 'entry_date is required' }); - const entry = svc.createEntry(Number(req.params.id), authReq.user.id, req.body, req.headers['x-socket-id'] as string); - if (!entry) return res.status(404).json({ error: 'Journey not found' }); - res.status(201).json(entry); -}); - -router.put('/:id/entries/reorder', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const orderedIds = (req.body || {}).orderedIds; - if (!Array.isArray(orderedIds) || !orderedIds.every(id => Number.isFinite(Number(id)))) { - return res.status(400).json({ error: 'orderedIds must be an array of numbers' }); - } - if (!svc.reorderEntries(Number(req.params.id), authReq.user.id, orderedIds.map(Number), req.headers['x-socket-id'] as string)) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -// ── Contributors ───────────────────────────────────────────────────────── - -router.post('/:id/contributors', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { user_id, role } = req.body || {}; - if (!user_id) return res.status(400).json({ error: 'user_id required' }); - if (!svc.addContributor(Number(req.params.id), authReq.user.id, user_id, role || 'viewer')) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.status(201).json({ success: true }); -}); - -router.patch('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { role } = req.body || {}; - if (!svc.updateContributorRole(Number(req.params.id), authReq.user.id, Number(req.params.userId), role)) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -router.delete('/:id/contributors/:userId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!svc.removeContributor(Number(req.params.id), authReq.user.id, Number(req.params.userId))) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -// ── User Preferences ───────────────────────────────────────────────────── - -router.patch('/:id/preferences', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = svc.updateJourneyPreferences(Number(req.params.id), authReq.user.id, req.body); - if (!result) return res.status(403).json({ error: 'Not allowed' }); - res.json(result); -}); - -// ── Share Link ──────────────────────────────────────────────────────────── - -router.get('/:id/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const link = getJourneyShareLink(Number(req.params.id)); - res.json({ link }); -}); - -router.post('/:id/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { share_timeline, share_gallery, share_map } = req.body || {}; - const result = createOrUpdateJourneyShareLink(Number(req.params.id), authReq.user.id, { share_timeline, share_gallery, share_map }); - if (!result) return res.status(403).json({ error: 'Not allowed' }); - res.json(result); -}); - -router.delete('/:id/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!deleteJourneyShareLink(Number(req.params.id), authReq.user.id)) { - return res.status(403).json({ error: 'Not allowed' }); - } - res.json({ success: true }); -}); - -export default router; diff --git a/server/src/routes/journeyPublic.ts b/server/src/routes/journeyPublic.ts deleted file mode 100644 index 37dd167e..00000000 --- a/server/src/routes/journeyPublic.ts +++ /dev/null @@ -1,56 +0,0 @@ -import express, { Request, Response } from 'express'; -import { getPublicJourney, validateShareTokenForAsset, validateShareTokenForPhoto } from '../services/journeyShareService'; -import { streamPhoto } from '../services/memories/photoResolverService'; -import { streamImmichAsset } from '../services/memories/immichService'; -import path from 'node:path'; -import fs from 'node:fs'; - -const router = express.Router(); - -router.get('/:token', (req: Request, res: Response) => { - const data = getPublicJourney(req.params.token); - if (!data) return res.status(404).json({ error: 'Not found' }); - res.json(data); -}); - -// Unified public photo proxy — uses trek_photo_id -router.get('/:token/photos/:photoId/:kind', async (req: Request, res: Response) => { - const { token, photoId, kind } = req.params; - const valid = validateShareTokenForPhoto(token, Number(photoId)); - if (!valid) return res.status(404).json({ error: 'Not found' }); - - await streamPhoto(res, valid.ownerId, Number(photoId), kind === 'thumbnail' ? 'thumbnail' : 'original'); -}); - -// Legacy public photo proxy — validates share token instead of auth -router.get('/:token/photo/:provider/:assetId/:ownerId/:kind', async (req: Request, res: Response) => { - const { token, provider, assetId, ownerId, kind } = req.params; - - const valid = validateShareTokenForAsset(token, assetId); - if (!valid) return res.status(404).json({ error: 'Not found' }); - - if (provider === 'local') { - const filePath = path.join(__dirname, '../../uploads/journey', assetId); - const resolved = path.resolve(filePath); - const uploadsDir = path.resolve(__dirname, '../../uploads'); - if (!resolved.startsWith(uploadsDir) || !fs.existsSync(resolved)) { - return res.status(404).json({ error: 'Not found' }); - } - res.set('Cache-Control', 'public, max-age=86400'); - return res.sendFile(resolved); - } - - const effectiveOwnerId = valid.ownerId || Number(ownerId); - if (provider === 'immich') { - await streamImmichAsset(res, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original', effectiveOwnerId); - } else { - try { - const { streamSynologyAsset } = await import('../services/memories/synologyService'); - await streamSynologyAsset(res, effectiveOwnerId, effectiveOwnerId, assetId, kind === 'thumbnail' ? 'thumbnail' : 'original'); - } catch { - res.status(404).json({ error: 'Provider not supported' }); - } - } -}); - -export default router; diff --git a/server/src/routes/maps.ts b/server/src/routes/maps.ts deleted file mode 100644 index 427d4500..00000000 --- a/server/src/routes/maps.ts +++ /dev/null @@ -1,162 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { - searchPlaces, - getPlaceDetails, - getPlaceDetailsExpanded, - getPlacePhoto, - reverseGeocode, - resolveGoogleMapsUrl, - autocompletePlaces, -} from '../services/mapsService'; -import { db } from '../db/database'; -import { serveFilePath } from '../services/placePhotoCache'; - -const router = express.Router(); - -// POST /search -router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { query } = req.body; - - if (!query) return res.status(400).json({ error: 'Search query is required' }); - - try { - const result = await searchPlaces(authReq.user.id, query, req.query.lang as string); - res.json(result); - } catch (err: unknown) { - const status = (err as { status?: number }).status || 500; - const message = err instanceof Error ? err.message : 'Search error'; - console.error('Maps search error:', err); - res.status(status).json({ error: message }); - } -}); - -// POST /autocomplete -router.post('/autocomplete', authenticate, async (req: Request, res: Response) => { - const autocompleteEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_autocomplete_enabled'").get() as { value: string } | undefined; - if (autocompleteEnabledRow?.value === 'false') return res.status(200).json({ suggestions: [], source: 'disabled' }); - - const authReq = req as AuthRequest; - const { input, lang, locationBias } = req.body; - - if (!input || typeof input !== 'string') { - return res.status(400).json({ error: 'Input is required' }); - } - - if (input.length > 200) { - return res.status(400).json({ error: 'Input too long (max 200 chars)' }); - } - - if (locationBias) { - const { low, high } = locationBias; - if (!low || !high - || !Number.isFinite(low.lat) || !Number.isFinite(low.lng) - || !Number.isFinite(high.lat) || !Number.isFinite(high.lng)) { - return res.status(400).json({ error: 'Invalid locationBias: low and high must have finite lat and lng' }); - } - } - - try { - const result = await autocompletePlaces( - authReq.user.id, - input, - lang as string, - locationBias as { low: { lat: number; lng: number }; high: { lat: number; lng: number } } | undefined, - ); - res.json(result); - } catch (err: unknown) { - const status = (err as { status?: number }).status || 500; - const message = err instanceof Error ? err.message : 'Autocomplete error'; - console.error('Maps autocomplete error:', err); - res.status(status).json({ error: message }); - } -}); - -// GET /details/:placeId -router.get('/details/:placeId', authenticate, async (req: Request, res: Response) => { - const detailsEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_details_enabled'").get() as { value: string } | undefined; - if (detailsEnabledRow?.value === 'false') return res.status(200).json({ place: null, disabled: true }); - - const authReq = req as AuthRequest; - const { placeId } = req.params; - const expand = req.query.expand as string | undefined; - - try { - const refresh = req.query.refresh === '1'; - const result = expand - ? await getPlaceDetailsExpanded(authReq.user.id, placeId, req.query.lang as string, refresh) - : await getPlaceDetails(authReq.user.id, placeId, req.query.lang as string); - res.json(result); - } catch (err: unknown) { - const status = (err as { status?: number }).status || 500; - const message = err instanceof Error ? err.message : 'Error fetching place details'; - console.error('Maps details error:', err); - res.status(status).json({ error: message }); - } -}); - -// GET /place-photo/:placeId -router.get('/place-photo/:placeId', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { placeId } = req.params; - - // Kill-switch only applies to Google Places API fetches — Wikimedia (coords: prefix) is always allowed - if (!placeId.startsWith('coords:')) { - const photosEnabledRow = db.prepare("SELECT value FROM app_settings WHERE key = 'places_photos_enabled'").get() as { value: string } | undefined; - if (photosEnabledRow?.value === 'false') return res.status(200).json({ photoUrl: null }); - } - const lat = parseFloat(req.query.lat as string); - const lng = parseFloat(req.query.lng as string); - - try { - const result = await getPlacePhoto(authReq.user.id, placeId, lat, lng, req.query.name as string); - res.json(result); - } catch (err: unknown) { - const status = (err as { status?: number }).status || 500; - const message = err instanceof Error ? err.message : 'Error fetching photo'; - if (status >= 500) console.error('Place photo error:', err); - res.status(status).json({ error: message }); - } -}); - -// GET /place-photo/:placeId/bytes — serve cached photo bytes from disk -router.get('/place-photo/:placeId/bytes', authenticate, (req: Request, res: Response) => { - const { placeId } = req.params; - const fp = serveFilePath(placeId); - if (!fp) return res.status(404).json({ error: 'Photo not cached' }); - res.set('Cache-Control', 'public, max-age=2592000, immutable'); - res.sendFile(fp); -}); - -// GET /reverse -router.get('/reverse', authenticate, async (req: Request, res: Response) => { - const { lat, lng, lang } = req.query as { lat: string; lng: string; lang?: string }; - if (!lat || !lng) return res.status(400).json({ error: 'lat and lng required' }); - - try { - const result = await reverseGeocode(lat, lng, lang); - res.json(result); - } catch { - res.json({ name: null, address: null }); - } -}); - -// POST /resolve-url -router.post('/resolve-url', authenticate, async (req: Request, res: Response) => { - const { url } = req.body; - if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); - - try { - const result = await resolveGoogleMapsUrl(url); - res.json(result); - } catch (err: unknown) { - const status = (err as { status?: number }).status || 400; - const message = err instanceof Error ? err.message : 'Failed to resolve URL'; - console.error('[Maps] URL resolve error:', message); - res.status(status).json({ error: message }); - } -}); - -export default router; diff --git a/server/src/routes/memories/immich.ts b/server/src/routes/memories/immich.ts deleted file mode 100644 index c12b4c6e..00000000 --- a/server/src/routes/memories/immich.ts +++ /dev/null @@ -1,142 +0,0 @@ -import express, { Request, Response } from 'express'; -import { canAccessTrip } from '../../db/database'; -import { authenticate } from '../../middleware/auth'; -import { broadcast } from '../../websocket'; -import { AuthRequest } from '../../types'; -import { getClientIp } from '../../services/auditLog'; -import { - getConnectionSettings, - saveImmichSettings, - setImmichAutoUpload, - testConnection, - getConnectionStatus, - browseTimeline, - searchPhotos, - streamImmichAsset, - listAlbums, - getAlbumPhotos, - syncAlbumAssets, - getAssetInfo, - isValidAssetId, -} from '../../services/memories/immichService'; -import { canAccessUserPhoto } from '../../services/memories/helpersService'; - -const router = express.Router(); - -// ── Immich Connection Settings ───────────────────────────────────────────── - -router.get('/settings', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(getConnectionSettings(authReq.user.id)); -}); - -router.put('/settings', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { immich_url, immich_api_key, auto_upload } = req.body; - const result = await saveImmichSettings(authReq.user.id, immich_url, immich_api_key, getClientIp(req)); - if (!result.success) return res.status(400).json({ error: result.error }); - if (typeof auto_upload === 'boolean') { - setImmichAutoUpload(authReq.user.id, auto_upload); - } - if (result.warning) return res.json({ success: true, warning: result.warning }); - res.json({ success: true }); -}); - -router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(await getConnectionStatus(authReq.user.id)); -}); - -router.post('/test', authenticate, async (req: Request, res: Response) => { - const { immich_url, immich_api_key } = req.body; - if (!immich_url || !immich_api_key) return res.json({ connected: false, error: 'URL and API key required' }); - res.json(await testConnection(immich_url, immich_api_key)); -}); - -// ── Browse Immich Library (for photo picker) ─────────────────────────────── - -router.get('/browse', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await browseTimeline(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ buckets: result.buckets }); -}); - -router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { from, to, size, page } = req.body; - const pageNum = Math.max(1, Number(page) || 1); - const pageSize = Math.min(Number(size) || 50, 200); - const result = await searchPhotos(authReq.user.id, from, to, pageNum, pageSize); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ assets: result.assets || [], hasMore: !!result.hasMore }); -}); - -// ── Asset Details ────────────────────────────────────────────────────────── - -router.get('/assets/:tripId/:assetId/:ownerId/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, assetId, ownerId } = req.params; - - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { - return res.status(403).json({ error: 'Forbidden' }); - } - const result = await getAssetInfo(authReq.user.id, assetId, Number(ownerId)); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json(result.data); -}); - -// ── Proxy Immich Assets ──────────────────────────────────────────────────── - -router.get('/assets/:tripId/:assetId/:ownerId/thumbnail', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, assetId, ownerId } = req.params; - - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { - return res.status(403).json({ error: 'Forbidden' }); - } - await streamImmichAsset(res, authReq.user.id, assetId, 'thumbnail', Number(ownerId)); -}); - -router.get('/assets/:tripId/:assetId/:ownerId/original', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, assetId, ownerId } = req.params; - - if (!isValidAssetId(assetId)) return res.status(400).json({ error: 'Invalid asset ID' }); - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, assetId, 'immich')) { - return res.status(403).json({ error: 'Forbidden' }); - } - await streamImmichAsset(res, authReq.user.id, assetId, 'original', Number(ownerId)); -}); - -// ── Album Linking ────────────────────────────────────────────────────────── - -router.get('/albums', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await listAlbums(authReq.user.id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ albums: result.albums }); -}); - -router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const result = await getAlbumPhotos(authReq.user.id, req.params.albumId); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ assets: result.assets }); -}); - -router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; - const sid = req.headers['x-socket-id'] as string; - const result = await syncAlbumAssets(tripId, linkId, authReq.user.id, sid); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true, added: result.added, total: result.total }); - if (result.added! > 0) { - broadcast(tripId, 'memories:updated', { userId: authReq.user.id }, req.headers['x-socket-id'] as string); - } -}); - -export default router; diff --git a/server/src/routes/memories/synology.ts b/server/src/routes/memories/synology.ts deleted file mode 100644 index bb325f4d..00000000 --- a/server/src/routes/memories/synology.ts +++ /dev/null @@ -1,150 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../../middleware/auth'; -import { AuthRequest } from '../../types'; -import { - getSynologySettings, - updateSynologySettings, - getSynologyStatus, - testSynologyConnection, - listSynologyAlbums, - getSynologyAlbumPhotos, - syncSynologyAlbumLink, - searchSynologyPhotos, - getSynologyAssetInfo, - streamSynologyAsset, -} from '../../services/memories/synologyService'; -import { canAccessUserPhoto, handleServiceResult, fail, success } from '../../services/memories/helpersService'; - -const router = express.Router(); - -function _parseStringBodyField(value: unknown): string { - return String(value ?? '').trim(); -} - -function _parseNumberBodyField(value: unknown, fallback: number): number { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : fallback; -} - -router.get('/settings', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - handleServiceResult(res, await getSynologySettings(authReq.user.id)); -}); - -router.put('/settings', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const body = req.body as Record; - const synology_url = _parseStringBodyField(body.synology_url); - const synology_username = _parseStringBodyField(body.synology_username); - const synology_password = _parseStringBodyField(body.synology_password); - const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; - - if (!synology_url || !synology_username) { - handleServiceResult(res, fail('URL and username are required', 400)); - } - else { - handleServiceResult(res, await updateSynologySettings(authReq.user.id, synology_url, synology_username, synology_password, synology_skip_ssl)); - } -}); - -router.get('/status', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - handleServiceResult(res, await getSynologyStatus(authReq.user.id)); -}); - -router.post('/test', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const body = req.body as Record; - const synology_url = _parseStringBodyField(body.synology_url); - const synology_username = _parseStringBodyField(body.synology_username); - const synology_password = _parseStringBodyField(body.synology_password); - const synology_otp = _parseStringBodyField(body.synology_otp); - const synology_skip_ssl = body.synology_skip_ssl === true || body.synology_skip_ssl === 'true'; - - if (!synology_url || !synology_username || !synology_password) { - const missingFields: string[] = []; - if (!synology_url) missingFields.push('URL'); - if (!synology_username) missingFields.push('Username'); - if (!synology_password) missingFields.push('Password'); - handleServiceResult(res, success({ connected: false, error: `${missingFields.join(', ')} ${missingFields.length > 1 ? 'are' : 'is'} required` })); - } - else{ - handleServiceResult(res, await testSynologyConnection(authReq.user.id, synology_url, synology_username, synology_password, synology_otp, synology_skip_ssl)); - } -}); - -router.get('/albums', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - handleServiceResult(res, await listSynologyAlbums(authReq.user.id)); -}); - -router.get('/albums/:albumId/photos', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined; - handleServiceResult(res, await getSynologyAlbumPhotos(authReq.user.id, req.params.albumId, passphrase)); -}); - -router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; - const sid = req.headers['x-socket-id'] as string; - - handleServiceResult(res, await syncSynologyAlbumLink(authReq.user.id, tripId, linkId, sid)); -}); - -router.post('/search', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const body = req.body as Record; - const from = _parseStringBodyField(body.from); - const to = _parseStringBodyField(body.to); - let offset = _parseNumberBodyField(body.offset, 0); - const page = _parseNumberBodyField(body.page, 1) - 1; - let limit = _parseNumberBodyField(body.limit, 100); - const size = _parseNumberBodyField(body.size, 0); - if (size > 0) limit = size; - if (page > 0) offset = page * limit; - - handleServiceResult(res, await searchSynologyPhotos( - authReq.user.id, - from || undefined, - to || undefined, - offset, - limit, - )); -}); - -router.get('/assets/:tripId/:photoId/:ownerId/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, photoId, ownerId } = req.params; - const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined; - - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - handleServiceResult(res, fail('You don\'t have access to this photo', 403)); - } - else { - handleServiceResult(res, await getSynologyAssetInfo(authReq.user.id, photoId, Number(ownerId), passphrase)); - } -}); - -router.get('/assets/:tripId/:photoId/:ownerId/:kind', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, photoId, ownerId, kind } = req.params; - const VALID_SIZES = ['sm', 'm', 'xl'] as const; - const rawSize = String(req.query.size ?? 'sm'); - const size = VALID_SIZES.includes(rawSize as any) ? rawSize : 'sm'; - const passphrase = req.query.passphrase ? String(req.query.passphrase) : undefined; - - if (kind !== 'thumbnail' && kind !== 'original') { - return handleServiceResult(res, fail('Invalid asset kind', 400)); - } - - if (!canAccessUserPhoto(authReq.user.id, Number(ownerId), tripId, photoId, 'synologyphotos')) { - handleServiceResult(res, fail('You don\'t have access to this photo', 403)); - } - else{ - await streamSynologyAsset(res, authReq.user.id, Number(ownerId), photoId, kind as 'thumbnail' | 'original', String(size), passphrase); - } - -}); - -export default router; diff --git a/server/src/routes/memories/unified.ts b/server/src/routes/memories/unified.ts deleted file mode 100644 index 5e4b1d2a..00000000 --- a/server/src/routes/memories/unified.ts +++ /dev/null @@ -1,104 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../../middleware/auth'; -import { AuthRequest } from '../../types'; -import { - listTripPhotos, - listTripAlbumLinks, - createTripAlbumLink, - removeAlbumLink, - addTripPhotos, - removeTripPhoto, - setTripPhotoSharing, -} from '../../services/memories/unifiedService'; -import immichRouter from './immich'; -import synologyRouter from './synology'; -import { Selection } from '../../services/memories/helpersService'; - -const router = express.Router(); - -router.use('/immich', immichRouter); -router.use('/synologyphotos', synologyRouter); - -//------------------------------------------------ -// routes for managing photos linked to trip - -router.get('/unified/trips/:tripId/photos', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = listTripPhotos(tripId, authReq.user.id); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ photos: result.data }); -}); - -router.post('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const sid = req.headers['x-socket-id'] as string; - const selections: Selection[] = Array.isArray(req.body?.selections) ? req.body.selections : []; - - const shared = req.body?.shared === undefined ? true : !!req.body?.shared; - const result = await addTripPhotos( - tripId, - authReq.user.id, - shared, - selections, - sid, - ); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - - res.json({ success: true, added: result.data.added }); -}); - -router.put('/unified/trips/:tripId/photos/sharing', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = await setTripPhotoSharing( - tripId, - authReq.user.id, - Number(req.body?.photo_id), - req.body?.shared, - ); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true }); -}); - -router.delete('/unified/trips/:tripId/photos', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = removeTripPhoto(tripId, authReq.user.id, Number(req.body?.photo_id)); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true }); -}); - -//------------------------------ -// routes for managing album links - -router.get('/unified/trips/:tripId/album-links', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const result = listTripAlbumLinks(tripId, authReq.user.id); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ links: result.data }); -}); - -router.post('/unified/trips/:tripId/album-links', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const passphrase = req.body?.passphrase ? String(req.body.passphrase) : undefined; - const result = createTripAlbumLink(tripId, authReq.user.id, req.body?.provider, req.body?.album_id, req.body?.album_name, passphrase); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true }); -}); - -router.delete('/unified/trips/:tripId/album-links/:linkId', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, linkId } = req.params; - const result = removeAlbumLink(tripId, linkId, authReq.user.id); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json({ success: true }); -}); - - - - -export default router; diff --git a/server/src/routes/notifications.ts b/server/src/routes/notifications.ts deleted file mode 100644 index 9f8e7c27..00000000 --- a/server/src/routes/notifications.ts +++ /dev/null @@ -1,153 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { testSmtp, testWebhook, testNtfy, getAdminWebhookUrl, getUserWebhookUrl, getUserNtfyConfig, getAdminNtfyConfig } from '../services/notifications'; -import { - getNotifications, - getUnreadCount, - markRead, - markUnread, - markAllRead, - deleteNotification, - deleteAll, - respondToBoolean, -} from '../services/inAppNotifications'; -import { getPreferencesMatrix, setPreferences } from '../services/notificationPreferencesService'; - -const router = express.Router(); - -router.get('/preferences', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user')); -}); - -router.put('/preferences', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - setPreferences(authReq.user.id, req.body); - res.json(getPreferencesMatrix(authReq.user.id, authReq.user.role, 'user')); -}); - -router.post('/test-smtp', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (authReq.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); - const { email } = req.body; - res.json(await testSmtp(email || authReq.user.email)); -}); - -router.post('/test-webhook', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - let { url } = req.body; - if (!url || url === '••••••••') { - url = getUserWebhookUrl(authReq.user.id); - if (!url && authReq.user.role === 'admin') url = getAdminWebhookUrl(); - if (!url) return res.status(400).json({ error: 'No webhook URL configured' }); - } - if (typeof url !== 'string') return res.status(400).json({ error: 'url must be a string' }); - try { new URL(url); } catch { return res.status(400).json({ error: 'Invalid URL' }); } - res.json(await testWebhook(url)); -}); - -router.post('/test-ntfy', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { topic, server, token } = req.body as { topic?: string; server?: string; token?: string }; - - // Always load saved config for fallbacks (token may be masked or absent in request) - const userCfg = getUserNtfyConfig(authReq.user.id); - const adminCfg = getAdminNtfyConfig(); - - const resolvedTopic = topic || userCfg?.topic || undefined; - const resolvedServer = server || userCfg?.server || adminCfg.server || undefined; - // Reuse saved token when request sends null, empty, or the masked placeholder - const resolvedToken = (token && token !== '••••••••') - ? token - : (userCfg?.token ?? adminCfg.token ?? null); - - if (!resolvedTopic) return res.status(400).json({ error: 'No ntfy topic configured' }); - - res.json(await testNtfy({ topic: resolvedTopic, server: resolvedServer ?? null, token: resolvedToken })); -}); - -// ── In-app notifications ────────────────────────────────────────────────────── - -// GET /in-app — list notifications (paginated) -router.get('/in-app', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const limit = Math.min(parseInt(req.query.limit as string) || 20, 50); - const offset = parseInt(req.query.offset as string) || 0; - const unreadOnly = req.query.unread_only === 'true'; - - const result = getNotifications(authReq.user.id, { limit, offset, unreadOnly }); - res.json(result); -}); - -// GET /in-app/unread-count — badge count -router.get('/in-app/unread-count', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const count = getUnreadCount(authReq.user.id); - res.json({ count }); -}); - -// PUT /in-app/read-all — mark all read (must be before /:id routes) -router.put('/in-app/read-all', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const count = markAllRead(authReq.user.id); - res.json({ success: true, count }); -}); - -// DELETE /in-app/all — delete all (must be before /:id routes) -router.delete('/in-app/all', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const count = deleteAll(authReq.user.id); - res.json({ success: true, count }); -}); - -// PUT /in-app/:id/read — mark single read -router.put('/in-app/:id/read', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); - - const ok = markRead(id, authReq.user.id); - if (!ok) return res.status(404).json({ error: 'Not found' }); - res.json({ success: true }); -}); - -// PUT /in-app/:id/unread — mark single unread -router.put('/in-app/:id/unread', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); - - const ok = markUnread(id, authReq.user.id); - if (!ok) return res.status(404).json({ error: 'Not found' }); - res.json({ success: true }); -}); - -// DELETE /in-app/:id — delete single -router.delete('/in-app/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); - - const ok = deleteNotification(id, authReq.user.id); - if (!ok) return res.status(404).json({ error: 'Not found' }); - res.json({ success: true }); -}); - -// POST /in-app/:id/respond — respond to a boolean notification -router.post('/in-app/:id/respond', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - if (isNaN(id)) return res.status(400).json({ error: 'Invalid id' }); - - const { response } = req.body; - if (response !== 'positive' && response !== 'negative') { - return res.status(400).json({ error: 'response must be "positive" or "negative"' }); - } - - const result = await respondToBoolean(id, authReq.user.id, response); - if (!result.success) return res.status(400).json({ error: result.error }); - res.json({ success: true, notification: result.notification }); -}); - -export default router; diff --git a/server/src/routes/oauth.ts b/server/src/routes/oauth.ts deleted file mode 100644 index e5666dc1..00000000 --- a/server/src/routes/oauth.ts +++ /dev/null @@ -1,416 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate, requireCookieAuth, optionalAuth } from '../middleware/auth'; -import { AuthRequest, OptionalAuthRequest } from '../types'; -import { isAddonEnabled } from '../services/adminService'; -import { ALL_SCOPES } from '../mcp/scopes'; -import { ADDON_IDS } from '../addons'; -import { - validateAuthorizeRequest, - createAuthCode, - consumeAuthCode, - saveConsent, - issueTokens, - issueClientCredentialsToken, - refreshTokens, - revokeToken, - verifyPKCE, - authenticateClient, - listOAuthClients, - createOAuthClient, - deleteOAuthClient, - rotateOAuthClientSecret, - listOAuthSessions, - revokeSession, - getUserByAccessToken, - AuthorizeParams, -} from '../services/oauthService'; -import { writeAudit, getClientIp, logWarn } from '../services/auditLog'; -import { getMcpSafeUrl } from '../services/notifications'; - -// --------------------------------------------------------------------------- -// Minimal in-file rate limiter (same pattern as auth.ts) -// --------------------------------------------------------------------------- - -interface RateEntry { count: number; first: number; } - -function makeRateLimiter(maxAttempts: number, windowMs: number, keyFn: (req: Request) => string) { - const store = new Map(); - setInterval(() => { - const now = Date.now(); - for (const [k, r] of store) if (now - r.first >= windowMs) store.delete(k); - }, windowMs).unref(); - - return (req: Request, res: Response, next: () => void): void => { - const key = keyFn(req); - const now = Date.now(); - const record = store.get(key); - if (record && record.count >= maxAttempts && now - record.first < windowMs) { - res.status(429).json({ error: 'too_many_requests', error_description: 'Too many attempts. Please try again later.' }); - return; - } - if (!record || now - record.first >= windowMs) { - store.set(key, { count: 1, first: now }); - } else { - record.count++; - } - next(); - }; -} - -const tokenLimiter = makeRateLimiter(30, 60_000, (req) => `${req.ip}|${req.body?.client_id ?? ''}`); -const validateLimiter = makeRateLimiter(30, 60_000, (req) => req.ip ?? 'unknown'); -const revokeLimiter = makeRateLimiter(10, 60_000, (req) => req.ip ?? 'unknown'); - -// --------------------------------------------------------------------------- -// Public router: /oauth/token and /oauth/revoke -// (/.well-known and /oauth/register are now handled by SDK in app.ts) -// --------------------------------------------------------------------------- - -export const oauthPublicRouter = express.Router(); - -// Token endpoint — handles authorization_code and refresh_token grants -oauthPublicRouter.post('/oauth/token', tokenLimiter, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - // M1: RFC 6749 §5.1 — token responses must not be cached - res.set('Cache-Control', 'no-store'); - res.set('Pragma', 'no-cache'); - - // Accept both JSON and application/x-www-form-urlencoded - const body: Record = typeof req.body === 'object' ? req.body : {}; - const { grant_type, code, redirect_uri, client_id, client_secret, code_verifier, refresh_token, resource } = body; - const ip = getClientIp(req); - - if (!client_id) { - return res.status(401).json({ error: 'invalid_client', error_description: 'client_id is required' }); - } - - // ---- authorization_code grant ---- - if (grant_type === 'authorization_code') { - if (!code || !redirect_uri || !code_verifier) { - return res.status(400).json({ error: 'invalid_request', error_description: 'code, redirect_uri, and code_verifier are required' }); - } - - const pending = consumeAuthCode(code); - - // H5: collapse all invalid_grant cases to one message; log specifics server-side - if (!pending) { - writeAudit({ userId: null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'code_invalid_or_expired' }, ip }); - return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); - } - - if (pending.clientId !== client_id) { - writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'client_id_mismatch' }, ip }); - return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); - } - - if (pending.redirectUri !== redirect_uri) { - writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'redirect_uri_mismatch' }, ip }); - return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); - } - - // RFC 8707: if the auth code was bound to a resource, the token request must present the same value - if (pending.resource && resource && pending.resource !== resource.replace(/\/+$/, '')) { - writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'resource_mismatch' }, ip }); - return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); - } - - // Verify client secret - if (!authenticateClient(client_id, client_secret)) { - logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: pending.userId, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); - return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); - } - - // Verify PKCE - if (!verifyPKCE(code_verifier, pending.codeChallenge)) { - writeAudit({ userId: pending.userId, action: 'oauth.token.grant_failed', details: { client_id, reason: 'pkce_failed' }, ip }); - return res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization grant is invalid.' }); - } - - const tokens = issueTokens(client_id, pending.userId, pending.scopes, null, pending.resource ?? null); - writeAudit({ userId: pending.userId, action: 'oauth.token.issue', details: { client_id, scopes: pending.scopes, audience: pending.resource ?? null }, ip }); - return res.json(tokens); - } - - // ---- refresh_token grant ---- - if (grant_type === 'refresh_token') { - if (!refresh_token) { - return res.status(400).json({ error: 'invalid_request', error_description: 'refresh_token is required' }); - } - - const result = refreshTokens(refresh_token, client_id, client_secret, ip); - if (result.error) { - if (result.error === 'invalid_client') { - logWarn(`[OAuth] Invalid client credentials on refresh for client_id=${client_id} ip=${ip ?? '-'}`); - } - return res.status(result.status || 400).json({ - error: result.error, - error_description: result.error === 'invalid_client' ? 'Invalid client credentials' : 'Refresh token is invalid or expired', - }); - } - - return res.json(result.tokens); - } - - // ---- client_credentials grant ---- - if (grant_type === 'client_credentials') { - if (!client_secret) { - return res.status(401).json({ error: 'invalid_client', error_description: 'client_secret is required for client_credentials grant' }); - } - - const client = authenticateClient(client_id, client_secret); - if (!client) { - logWarn(`[OAuth] Invalid client credentials for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id }, ip }); - return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); - } - - // Public clients and DCR-anonymous clients are ineligible for client_credentials. - if (client.is_public || !client.allows_client_credentials || client.user_id == null) { - writeAudit({ userId: client.user_id ?? null, action: 'oauth.token.grant_failed', details: { client_id, reason: 'unauthorized_client' }, ip }); - return res.status(400).json({ error: 'unauthorized_client', error_description: 'This client is not authorized for the client_credentials grant' }); - } - - // Scope: use requested subset or fall back to all allowed scopes. - const allowedScopes: string[] = JSON.parse(client.allowed_scopes); - let grantedScopes: string[]; - if (body.scope) { - const requested = body.scope.split(' ').filter(Boolean); - const invalid = requested.filter(s => !allowedScopes.includes(s)); - if (invalid.length > 0) { - return res.status(400).json({ error: 'invalid_scope', error_description: `Scopes not allowed for this client: ${invalid.join(', ')}` }); - } - grantedScopes = requested; - } else { - grantedScopes = allowedScopes; - } - - // Audience: honour RFC 8707 resource param; default to the MCP endpoint so the - // token passes audience binding in mcp/index.ts without extra configuration. - const audience = resource ? resource.replace(/\/+$/, '') : `${getMcpSafeUrl().replace(/\/+$/, '')}/mcp`; - - const tokens = issueClientCredentialsToken(client_id, client.user_id, grantedScopes, audience); - writeAudit({ userId: client.user_id, action: 'oauth.token.issue', details: { client_id, scopes: grantedScopes, audience, grant: 'client_credentials' }, ip }); - return res.json(tokens); - } - - return res.status(400).json({ error: 'unsupported_grant_type', error_description: `Unsupported grant_type: ${grant_type}` }); -}); - -// OIDC UserInfo endpoint (RFC 9068 / OpenID Connect Core §5.3) -// ChatGPT hits this after OAuth to fetch the authenticated user's email for domain claiming. -oauthPublicRouter.get('/oauth/userinfo', (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const auth = req.headers['authorization']; - if (!auth || !auth.toLowerCase().startsWith('bearer ')) { - res.set('WWW-Authenticate', 'Bearer realm="TREK MCP"'); - return res.status(401).json({ error: 'invalid_token' }); - } - const token = auth.slice(7); - const info = getUserByAccessToken(token); - if (!info) { - res.set('WWW-Authenticate', 'Bearer realm="TREK MCP", error="invalid_token"'); - return res.status(401).json({ error: 'invalid_token' }); - } - return res.json({ - sub: String(info.user.id), - email: info.user.email, - email_verified: true, - preferred_username: info.user.username, - }); -}); - -// Token revocation endpoint (RFC 7009) -oauthPublicRouter.post('/oauth/revoke', revokeLimiter, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - const body: Record = typeof req.body === 'object' ? req.body : {}; - const { token, client_id, client_secret } = body; - const ip = getClientIp(req); - - if (!token || !client_id) { - return res.status(400).json({ error: 'invalid_request', error_description: 'token and client_id are required' }); - } - - if (!authenticateClient(client_id, client_secret)) { - logWarn(`[OAuth] Invalid client credentials on revoke for client_id=${client_id} ip=${ip ?? '-'}`); - writeAudit({ userId: null, action: 'oauth.token.client_auth_failed', details: { client_id, endpoint: 'revoke' }, ip }); - return res.status(401).json({ error: 'invalid_client', error_description: 'Invalid client credentials' }); - } - - revokeToken(token, client_id, undefined, ip); - // RFC 7009 §2.2: always respond 200 even if token was already revoked or not found - return res.status(200).json({}); -}); - -// --------------------------------------------------------------------------- -// API router: /api/oauth/* — authenticated endpoints used by the SPA -// --------------------------------------------------------------------------- - -export const oauthApiRouter = express.Router(); - -// SPA calls this on page load to validate OAuth params before rendering consent UI -oauthApiRouter.get('/authorize/validate', validateLimiter, optionalAuth, (req: Request, res: Response) => { - // M2 / H3: gate by addon; 404 prevents feature fingerprinting for anonymous callers - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(404).end(); - - const params = req.query as Partial; - const userId = (req as OptionalAuthRequest).user?.id ?? null; - - const result = validateAuthorizeRequest( - { - response_type: params.response_type || '', - client_id: params.client_id || '', - redirect_uri: params.redirect_uri || '', - scope: params.scope || '', - state: params.state, - code_challenge: params.code_challenge || '', - code_challenge_method: params.code_challenge_method || '', - resource: typeof params.resource === 'string' ? params.resource : undefined, - }, - userId, - ); - - // H3: when caller is unauthenticated, strip client name / allowed_scopes from the response - // (validateAuthorizeRequest already does this, but be explicit here) - if (userId === null && result.valid) { - return res.json({ valid: result.valid, loginRequired: true }); - } - - // For unauthenticated error cases return a generic error to prevent oracle enumeration - if (userId === null && !result.valid) { - return res.json({ valid: false, error: 'invalid_request', error_description: 'Invalid authorization request' }); - } - - return res.json(result); -}); - -// User submits consent (approve or deny) — requires cookie-only auth (M7) -oauthApiRouter.post('/authorize', requireCookieAuth, (req: Request, res: Response) => { - const { user } = req as AuthRequest; - const { - client_id, redirect_uri, scope, state, - code_challenge, code_challenge_method, approved, resource, - } = req.body as { - client_id: string; - redirect_uri: string; - scope: string; - state?: string; - code_challenge: string; - code_challenge_method: string; - approved: boolean; - resource?: string; - }; - const ip = getClientIp(req); - - if (!isAddonEnabled(ADDON_IDS.MCP)) { - return res.status(403).json({ error: 'MCP is not enabled' }); - } - - if (!approved) { - // User denied — redirect with error - const url = new URL(redirect_uri); - url.searchParams.set('error', 'access_denied'); - url.searchParams.set('error_description', 'User denied the authorization request'); - if (state) url.searchParams.set('state', state); - return res.json({ redirect: url.toString() }); - } - - // Re-validate all params (server-side re-check after user action) - const params: AuthorizeParams = { - response_type: 'code', - client_id, - redirect_uri, - scope, - state, - code_challenge, - code_challenge_method, - resource, - }; - - const validation = validateAuthorizeRequest(params, user.id); - if (!validation.valid) { - return res.status(400).json({ error: validation.error, error_description: validation.error_description }); - } - - const scopes = validation.scopes!; - - // Store consent (union with any existing scopes) - saveConsent(client_id, user.id, scopes, ip); - - // Issue auth code - const code = createAuthCode({ - clientId: client_id, - userId: user.id, - redirectUri: redirect_uri, - scopes, - resource: validation.resource ?? null, - codeChallenge: code_challenge, - codeChallengeMethod: 'S256', - }); - - if (!code) { - return res.status(503).json({ error: 'server_error', error_description: 'Authorization server is temporarily unavailable' }); - } - - const url = new URL(redirect_uri); - url.searchParams.set('code', code); - if (state) url.searchParams.set('state', state); - - return res.json({ redirect: url.toString() }); -}); - -// ---- OAuth client CRUD ---- - -oauthApiRouter.get('/clients', authenticate, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - return res.json({ clients: listOAuthClients(user.id) }); -}); - -oauthApiRouter.post('/clients', requireCookieAuth, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - const { name, redirect_uris, allowed_scopes, allows_client_credentials } = req.body as { - name: string; - redirect_uris?: string[]; - allowed_scopes: string[]; - allows_client_credentials?: boolean; - }; - - const result = createOAuthClient(user.id, name, redirect_uris ?? [], allowed_scopes, getClientIp(req), { allowsClientCredentials: allows_client_credentials }); - if (result.error) return res.status(result.status || 400).json({ error: result.error }); - return res.status(201).json(result); -}); - -oauthApiRouter.post('/clients/:id/rotate', requireCookieAuth, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - const result = rotateOAuthClientSecret(user.id, req.params.id, getClientIp(req)); - if (result.error) return res.status(result.status || 400).json({ error: result.error }); - return res.json({ client_secret: result.client_secret }); -}); - -oauthApiRouter.delete('/clients/:id', requireCookieAuth, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - const result = deleteOAuthClient(user.id, req.params.id, getClientIp(req)); - if (result.error) return res.status(result.status || 400).json({ error: result.error }); - return res.json({ success: true }); -}); - -// ---- Active OAuth sessions ---- - -oauthApiRouter.get('/sessions', authenticate, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - return res.json({ sessions: listOAuthSessions(user.id) }); -}); - -oauthApiRouter.delete('/sessions/:id', requireCookieAuth, (req: Request, res: Response) => { - if (!isAddonEnabled(ADDON_IDS.MCP)) return res.status(403).json({ error: 'MCP is not enabled' }); - const { user } = req as AuthRequest; - const result = revokeSession(user.id, Number(req.params.id), getClientIp(req)); - if (result.error) return res.status(result.status || 400).json({ error: result.error }); - return res.json({ success: true }); -}); \ No newline at end of file diff --git a/server/src/routes/oidc.ts b/server/src/routes/oidc.ts deleted file mode 100644 index 27831dfe..00000000 --- a/server/src/routes/oidc.ts +++ /dev/null @@ -1,166 +0,0 @@ -import express, { Request, Response } from 'express'; -import { setAuthCookie } from '../services/cookie'; -import { - getOidcConfig, - discover, - createState, - consumeState, - createAuthCode, - consumeAuthCode, - exchangeCodeForToken, - getUserInfo, - verifyIdToken, - findOrCreateUser, - touchLastLogin, - generateToken, - frontendUrl, -} from '../services/oidcService'; -import { getAppUrl } from '../services/notifications'; -import { resolveAuthToggles } from '../services/authService'; - -const router = express.Router(); - -// ---- GET /login ---------------------------------------------------------- - -router.get('/login', async (req: Request, res: Response) => { - if (!resolveAuthToggles().oidc_login) { - return res.status(403).json({ error: 'SSO login is disabled.' }); - } - - const config = getOidcConfig(); - if (!config) return res.status(400).json({ error: 'OIDC not configured' }); - - if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') { - return res.status(400).json({ error: 'OIDC issuer must use HTTPS in production' }); - } - - try { - const doc = await discover(config.issuer, config.discoveryUrl); - const appUrl = getAppUrl(); - if (!appUrl) { - return res.status(500).json({ error: 'APP_URL is not configured. OIDC cannot be used.' }); - } - - const redirectUri = `${appUrl.replace(/\/+$/, '')}/api/auth/oidc/callback`; - const inviteToken = req.query.invite as string | undefined; - const { state, codeChallenge } = createState(redirectUri, inviteToken); - - const params = new URLSearchParams({ - response_type: 'code', - client_id: config.clientId, - redirect_uri: redirectUri, - scope: process.env.OIDC_SCOPE || 'openid email profile', - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - }); - - res.redirect(`${doc.authorization_endpoint}?${params}`); - } catch (err: unknown) { - console.error('[OIDC] Login error:', err instanceof Error ? err.message : err); - res.status(500).json({ error: 'OIDC login failed' }); - } -}); - -// ---- GET /callback ------------------------------------------------------- - -router.get('/callback', async (req: Request, res: Response) => { - if (!resolveAuthToggles().oidc_login) { - return res.redirect(frontendUrl('/login?oidc_error=sso_disabled')); - } - - const { code, state, error: oidcError } = req.query as { code?: string; state?: string; error?: string }; - - if (oidcError) { - console.error('[OIDC] Provider error:', oidcError); - return res.redirect(frontendUrl('/login?oidc_error=' + encodeURIComponent(oidcError))); - } - if (!code || !state) { - return res.redirect(frontendUrl('/login?oidc_error=missing_params')); - } - - const pending = consumeState(state); - if (!pending) { - return res.redirect(frontendUrl('/login?oidc_error=invalid_state')); - } - - const config = getOidcConfig(); - if (!config) return res.redirect(frontendUrl('/login?oidc_error=not_configured')); - - if (config.issuer && !config.issuer.startsWith('https://') && process.env.NODE_ENV?.toLowerCase() === 'production') { - return res.redirect(frontendUrl('/login?oidc_error=issuer_not_https')); - } - - try { - const doc = await discover(config.issuer, config.discoveryUrl); - - const tokenData = await exchangeCodeForToken(doc, code, pending.redirectUri, config.clientId, config.clientSecret, pending.codeVerifier); - if (!tokenData._ok || !tokenData.access_token) { - console.error('[OIDC] Token exchange failed: status', tokenData._status); - return res.redirect(frontendUrl('/login?oidc_error=token_failed')); - } - - // Strict id_token verification: signature via JWKS + iss + aud. - // Previously only the access_token was used to hit userinfo, so a - // compromised provider or MITM could supply a crafted userinfo - // response the server would blindly trust. When the id_token is - // missing from the token response (non-compliant provider) we still - // reject — an Authorization Code flow MUST return one per OIDC Core. - if (!tokenData.id_token) { - console.error('[OIDC] Token response missing id_token — refusing login'); - return res.redirect(frontendUrl('/login?oidc_error=no_id_token')); - } - const idVerify = await verifyIdToken( - tokenData.id_token, - doc, - config.clientId, - (doc.issuer ?? '').replace(/\/+$/, '') || config.issuer, - ); - if (idVerify.ok !== true) { - const reason = 'error' in idVerify ? idVerify.error : 'unknown'; - console.error('[OIDC] id_token verification failed:', reason); - return res.redirect(frontendUrl('/login?oidc_error=id_token_invalid')); - } - - const userInfo = await getUserInfo(doc.userinfo_endpoint, tokenData.access_token); - if (!userInfo.email) { - return res.redirect(frontendUrl('/login?oidc_error=no_email')); - } - // Cross-check: the userinfo response must be for the same subject - // the id_token signed. Catches a compromised userinfo endpoint that - // speaks for a different principal than the id_token's claim. - const tokenSub = idVerify.claims.sub; - if (typeof tokenSub === 'string' && userInfo.sub && userInfo.sub !== tokenSub) { - console.error('[OIDC] userinfo.sub does not match id_token.sub — refusing login'); - return res.redirect(frontendUrl('/login?oidc_error=subject_mismatch')); - } - - const result = findOrCreateUser(userInfo, config, pending.inviteToken); - if ('error' in result) { - return res.redirect(frontendUrl('/login?oidc_error=' + result.error)); - } - - touchLastLogin(result.user.id); - const jwtToken = generateToken(result.user); - const authCode = createAuthCode(jwtToken); - res.redirect(frontendUrl('/login?oidc_code=' + authCode)); - } catch (err: unknown) { - console.error('[OIDC] Callback error:', err); - res.redirect(frontendUrl('/login?oidc_error=server_error')); - } -}); - -// ---- GET /exchange ------------------------------------------------------- - -router.get('/exchange', (req: Request, res: Response) => { - const { code } = req.query as { code?: string }; - if (!code) return res.status(400).json({ error: 'Code required' }); - - const result = consumeAuthCode(code); - if ('error' in result) return res.status(400).json({ error: result.error }); - - setAuthCookie(res, result.token, req); - res.json({ token: result.token }); -}); - -export default router; diff --git a/server/src/routes/packing.ts b/server/src/routes/packing.ts deleted file mode 100644 index 7030b674..00000000 --- a/server/src/routes/packing.ts +++ /dev/null @@ -1,271 +0,0 @@ -import express, { Request, Response } from 'express'; -import { db } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { - verifyTripAccess, - listItems, - createItem, - updateItem, - deleteItem, - bulkImport, - listBags, - createBag, - updateBag, - deleteBag, - applyTemplate, - saveAsTemplate, - setBagMembers, - getCategoryAssignees, - updateCategoryAssignees, - reorderItems, -} from '../services/packingService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const items = listItems(tripId); - res.json({ items }); -}); - -// Bulk import packing items (must be before /:id) -router.post('/import', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { items } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'items must be a non-empty array' }); - - const created = bulkImport(tripId, items); - - res.status(201).json({ items: created, count: created.length }); - for (const item of created) { - broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string); - } -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { name, category, checked } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!name) return res.status(400).json({ error: 'Item name is required' }); - - const item = createItem(tripId, { name, category, checked }); - res.status(201).json({ item }); - broadcast(tripId, 'packing:created', { item }, req.headers['x-socket-id'] as string); -}); - -router.put('/reorder', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { orderedIds } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - reorderItems(tripId, orderedIds); - res.json({ success: true }); -}); - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { name, checked, category, weight_grams, bag_id, quantity } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const updated = updateItem(tripId, id, { name, checked, category, weight_grams, bag_id, quantity }, Object.keys(req.body)); - if (!updated) return res.status(404).json({ error: 'Item not found' }); - - res.json({ item: updated }); - broadcast(tripId, 'packing:updated', { item: updated }, req.headers['x-socket-id'] as string); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' }); - - res.json({ success: true }); - broadcast(tripId, 'packing:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -// ── Bags CRUD ─────────────────────────────────────────────────────────────── - -router.get('/bags', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - const bags = listBags(tripId); - res.json({ bags }); -}); - -router.post('/bags', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { name, color } = req.body; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!name?.trim()) return res.status(400).json({ error: 'Name is required' }); - const bag = createBag(tripId, { name, color }); - res.status(201).json({ bag }); - broadcast(tripId, 'packing:bag-created', { bag }, req.headers['x-socket-id'] as string); -}); - -router.put('/bags/:bagId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, bagId } = req.params; - const { name, color, weight_limit_grams, user_id } = req.body; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - const updated = updateBag(tripId, bagId, { name, color, weight_limit_grams, user_id }, Object.keys(req.body)); - if (!updated) return res.status(404).json({ error: 'Bag not found' }); - res.json({ bag: updated }); - broadcast(tripId, 'packing:bag-updated', { bag: updated }, req.headers['x-socket-id'] as string); -}); - -router.delete('/bags/:bagId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, bagId } = req.params; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - if (!deleteBag(tripId, bagId)) return res.status(404).json({ error: 'Bag not found' }); - res.json({ success: true }); - broadcast(tripId, 'packing:bag-deleted', { bagId: Number(bagId) }, req.headers['x-socket-id'] as string); -}); - -// ── Apply template ────────────────────────────────────────────────────────── - -router.post('/apply-template/:templateId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, templateId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const added = applyTemplate(tripId, templateId); - if (!added) return res.status(404).json({ error: 'Template not found or empty' }); - - res.json({ items: added, count: added.length }); - broadcast(tripId, 'packing:template-applied', { items: added }, req.headers['x-socket-id'] as string); -}); - -// ── Bag Members ──────────────────────────────────────────────────────────── - -router.put('/bags/:bagId/members', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, bagId } = req.params; - const { user_ids } = req.body; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - const members = setBagMembers(tripId, bagId, Array.isArray(user_ids) ? user_ids : []); - if (!members) return res.status(404).json({ error: 'Bag not found' }); - res.json({ members }); - broadcast(tripId, 'packing:bag-members-updated', { bagId: Number(bagId), members }, req.headers['x-socket-id'] as string); -}); - -// ── Save as Template ─────────────────────────────────────────────────────── - -router.post('/save-as-template', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { name } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!name?.trim()) return res.status(400).json({ error: 'Template name is required' }); - - const template = saveAsTemplate(tripId, authReq.user.id, name.trim()); - if (!template) return res.status(400).json({ error: 'No items to save' }); - - res.status(201).json({ template }); -}); - -// ── Category assignees ────────────────────────────────────────────────────── - -router.get('/category-assignees', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const assignees = getCategoryAssignees(tripId); - res.json({ assignees }); -}); - -router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, categoryName } = req.params; - const { user_ids } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const cat = decodeURIComponent(categoryName); - const rows = updateCategoryAssignees(tripId, cat, user_ids); - - res.json({ assignees: rows }); - broadcast(tripId, 'packing:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); - - // Notify newly assigned users - if (Array.isArray(user_ids) && user_ids.length > 0) { - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - // Use trip scope so the service resolves recipients — actor is excluded automatically - send({ event: 'packing_tagged', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, category: cat, tripId: String(tripId) } }).catch(() => {}); - }); - } -}); - -export default router; diff --git a/server/src/routes/photos.ts b/server/src/routes/photos.ts deleted file mode 100644 index f2f794b0..00000000 --- a/server/src/routes/photos.ts +++ /dev/null @@ -1,47 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import { streamPhoto, getPhotoInfo, resolveTrekPhoto } from '../services/memories/photoResolverService'; -import { canAccessTrekPhoto } from '../services/memories/helpersService'; - -const router = express.Router(); - -router.get('/:id/thumbnail', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const photoId = Number(req.params.id); - if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); - - if (!canAccessTrekPhoto(authReq.user.id, photoId)) { - return res.status(403).json({ error: 'Forbidden' }); - } - - await streamPhoto(res, authReq.user.id, photoId, 'thumbnail'); -}); - -router.get('/:id/original', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const photoId = Number(req.params.id); - if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); - - if (!canAccessTrekPhoto(authReq.user.id, photoId)) { - return res.status(403).json({ error: 'Forbidden' }); - } - - await streamPhoto(res, authReq.user.id, photoId, 'original'); -}); - -router.get('/:id/info', authenticate, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const photoId = Number(req.params.id); - if (!Number.isFinite(photoId)) return res.status(400).json({ error: 'Invalid photo ID' }); - - if (!canAccessTrekPhoto(authReq.user.id, photoId)) { - return res.status(403).json({ error: 'Forbidden' }); - } - - const result = await getPhotoInfo(authReq.user.id, photoId); - if ('error' in result) return res.status(result.error.status).json({ error: result.error.message }); - res.json(result.data); -}); - -export default router; diff --git a/server/src/routes/places.ts b/server/src/routes/places.ts deleted file mode 100644 index 3346fa14..00000000 --- a/server/src/routes/places.ts +++ /dev/null @@ -1,266 +0,0 @@ -import express, { Request, Response } from 'express'; -import multer from 'multer'; -import { authenticate } from '../middleware/auth'; -import { requireTripAccess } from '../middleware/tripAccess'; -import { broadcast } from '../websocket'; -import { validateStringLengths } from '../middleware/validate'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { - listPlaces, - createPlace, - getPlace, - updatePlace, - deletePlace, - deletePlacesMany, - importGpx, - importMapFile, - importGoogleList, - importNaverList, - searchPlaceImage, - type KmlImportOptions, -} from '../services/placeService'; -import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService'; - -const uploadMulter = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId } = req.params; - const { search, category, tag } = req.query; - - const places = listPlaces(tripId, { - search: search as string | undefined, - category: category as string | undefined, - tag: tag as string | undefined, - }); - - res.json({ places }); -}); - -router.post('/', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const { name } = req.body; - - if (!name) { - return res.status(400).json({ error: 'Place name is required' }); - } - - const place = createPlace(tripId, req.body); - res.status(201).json({ place }); - broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); - try { onPlaceCreated(Number(tripId), place.id); } catch {} -}); - -// Import places from GPX file with full track geometry (must be before /:id) -router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single('file'), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const file = req.file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'No file uploaded' }); - - const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true'; - const importWaypoints = parseBool(req.body.importWaypoints, true); - const importRoutes = parseBool(req.body.importRoutes, true); - const importTracks = parseBool(req.body.importTracks, true); - - if (!importWaypoints && !importRoutes && !importTracks) { - return res.status(400).json({ error: 'No import types selected' }); - } - - const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks }); - if (!result) { - return res.status(400).json({ error: 'No matching places found in GPX file' }); - } - - res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped }); - for (const place of result.places) { - broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); - } -}); - -router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single('file'), async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) { - return res.status(403).json({ error: 'No permission' }); - } - - const { tripId } = req.params; - const file = req.file as Express.Multer.File | undefined; - if (!file) return res.status(400).json({ error: 'No file uploaded' }); - - const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true'; - const importPoints = parseBool(req.body.importPoints, true); - const importPaths = parseBool(req.body.importPaths, true); - - if (!importPoints && !importPaths) { - return res.status(400).json({ error: 'No import types selected' }); - } - - const kmlOpts: KmlImportOptions = { importPoints, importPaths }; - - try { - const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts); - if (result.summary?.totalPlacemarks === 0) { - return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary }); - } - - res.status(201).json(result); - for (const place of result.places) { - broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - const message = err instanceof Error ? err.message : 'Failed to import map file'; - res.status(400).json({ error: message }); - } -}); - -// Import places from a shared Google Maps list URL -router.post('/import/google-list', authenticate, requireTripAccess, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const { url } = req.body; - if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); - - try { - const result = await importGoogleList(tripId, url); - - if ('error' in result) { - return res.status(result.status).json({ error: result.error }); - } - - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); - for (const place of result.places) { - broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - console.error('[Places] Google list import error:', err instanceof Error ? err.message : err); - res.status(400).json({ error: 'Failed to import Google Maps list. Make sure the list is shared publicly.' }); - } -}); - -// Import places from a shared Naver Maps list URL -router.post('/import/naver-list', authenticate, requireTripAccess, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - const { tripId } = req.params; - const { url } = req.body; - if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' }); - - try { - const result = await importNaverList(tripId, url); - - if ('error' in result) { - return res.status(result.status).json({ error: result.error }); - } - - res.status(201).json({ places: result.places, count: result.places.length, listName: result.listName, skipped: result.skipped }); - for (const place of result.places) { - broadcast(tripId, 'place:created', { place }, req.headers['x-socket-id'] as string); - } - } catch (err: unknown) { - console.error('[Places] Naver list import error:', err instanceof Error ? err.message : err); - res.status(400).json({ error: 'Failed to import Naver Maps list. Make sure the list is shared publicly.' }); - } -}); - -router.get('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const { tripId, id } = req.params; - - const place = getPlace(tripId, id); - if (!place) { - return res.status(404).json({ error: 'Place not found' }); - } - - res.json({ place }); -}); - -router.get('/:id/image', authenticate, requireTripAccess, async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - try { - const result = await searchPlaceImage(tripId, id, authReq.user.id); - - if ('error' in result) { - return res.status(result.status).json({ error: result.error }); - } - - res.json({ photos: result.photos }); - } catch (err: unknown) { - console.error('Unsplash error:', err); - res.status(500).json({ error: 'Error searching for image' }); - } -}); - -router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name: 200, description: 2000, address: 500, notes: 2000 }), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - const place = updatePlace(tripId, id, req.body); - if (!place) { - return res.status(404).json({ error: 'Place not found' }); - } - - res.json({ place }); - broadcast(tripId, 'place:updated', { place }, req.headers['x-socket-id'] as string); - try { onPlaceUpdated(place.id); } catch {} -}); - -// Bulk delete (must be before /:id) -router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId } = req.params; - const { ids } = req.body as { ids?: unknown }; - if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number')) - return res.status(400).json({ error: 'ids must be an array of numbers' }); - - const idList = ids as number[]; - if (idList.length === 0) return res.json({ deleted: [], count: 0 }); - - for (const id of idList) { try { onPlaceDeleted(id); } catch {} } - const deleted = deletePlacesMany(tripId, idList); - - res.json({ deleted, count: deleted.length }); - const socketId = req.headers['x-socket-id'] as string; - for (const id of deleted) { - broadcast(tripId, 'place:deleted', { placeId: id }, socketId); - } -}); - -router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { tripId, id } = req.params; - - try { onPlaceDeleted(Number(id)); } catch {} // sync before actual delete - const deleted = deletePlace(tripId, id); - if (!deleted) { - return res.status(404).json({ error: 'Place not found' }); - } - - res.json({ success: true }); - broadcast(tripId, 'place:deleted', { placeId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -export default router; diff --git a/server/src/routes/publicConfig.ts b/server/src/routes/publicConfig.ts deleted file mode 100644 index f39dbb28..00000000 --- a/server/src/routes/publicConfig.ts +++ /dev/null @@ -1,10 +0,0 @@ -import express, { Request, Response } from 'express'; -import { DEFAULT_LANGUAGE } from '../config'; - -const router = express.Router(); - -router.get('/', (_req: Request, res: Response) => { - res.json({ defaultLanguage: DEFAULT_LANGUAGE }); -}); - -export default router; diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts deleted file mode 100644 index 48e9ef03..00000000 --- a/server/src/routes/reservations.ts +++ /dev/null @@ -1,199 +0,0 @@ -import express, { Request, Response } from 'express'; -import { db } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { - verifyTripAccess, - listReservations, - createReservation, - updatePositions, - getReservation, - updateReservation, - deleteReservation, -} from '../services/reservationService'; -import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const reservations = listReservations(tripId); - res.json({ reservations }); -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!title) return res.status(400).json({ error: 'Title is required' }); - - const { reservation, accommodationCreated } = createReservation(tripId, { - title, reservation_time, reservation_end_time, location, - confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, - status, type, accommodation_id, metadata, create_accommodation, - endpoints, needs_review - }); - - if (accommodationCreated) { - broadcast(tripId, 'accommodation:created', {}, req.headers['x-socket-id'] as string); - } - - // Auto-create budget entry if price was provided - if (create_budget_entry && create_budget_entry.total_price > 0) { - try { - const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, { - name: title, - category: create_budget_entry.category || type || 'Other', - total_price: create_budget_entry.total_price, - }); - broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string); - } catch (err) { - console.error('[reservations] Failed to create budget entry:', err); - } - } - - res.status(201).json({ reservation }); - broadcast(tripId, 'reservation:created', { reservation }, req.headers['x-socket-id'] as string); - - // Notify trip members about new booking - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title, type: type || 'booking', tripId: String(tripId) } }).catch(() => {}); - }); -}); - -// Batch update day_plan_position for multiple reservations (must be before /:id) -router.put('/positions', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { positions } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!Array.isArray(positions)) return res.status(400).json({ error: 'positions must be an array' }); - - const { day_id } = req.body; - updatePositions(tripId, positions, day_id); - - res.json({ success: true }); - broadcast(tripId, 'reservation:positions', { positions, day_id }, req.headers['x-socket-id'] as string); -}); - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const current = getReservation(id, tripId); - if (!current) return res.status(404).json({ error: 'Reservation not found' }); - - const { reservation, accommodationChanged } = updateReservation(id, tripId, { - title, reservation_time, reservation_end_time, location, - confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, - status, type, accommodation_id, metadata, create_accommodation, - endpoints, needs_review - }, current); - - if (accommodationChanged) { - broadcast(tripId, 'accommodation:updated', {}, req.headers['x-socket-id'] as string); - } - - // Remove linked budget entry if price was cleared - if (!create_budget_entry || !create_budget_entry.total_price) { - const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; - if (linked) { - deleteBudgetItem(linked.id, tripId); - broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string); - } - } - - // Auto-create or update budget entry if price was provided - if (create_budget_entry && create_budget_entry.total_price > 0) { - try { - const itemName = title || current.title; - const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; - if (existing) { - const updated = updateBudgetItem(existing.id, tripId, { - name: itemName, - category: create_budget_entry.category || type || current.type || 'Other', - total_price: create_budget_entry.total_price, - }); - broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id'] as string); - } else { - const budgetItem = createBudgetItem(tripId, { - name: itemName, - category: create_budget_entry.category || type || current.type || 'Other', - total_price: create_budget_entry.total_price, - }); - db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, budgetItem.id); - budgetItem.reservation_id = Number(id); - broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string); - } - } catch (err) { - console.error('[reservations] Failed to create/update budget entry:', err); - } - } - - res.json({ reservation }); - broadcast(tripId, 'reservation:updated', { reservation }, req.headers['x-socket-id'] as string); - - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: title || current.title, type: type || current.type || 'booking', tripId: String(tripId) } }).catch(() => {}); - }); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { deleted: reservation, accommodationDeleted, deletedBudgetItemId } = deleteReservation(id, tripId); - if (!reservation) return res.status(404).json({ error: 'Reservation not found' }); - - if (accommodationDeleted) { - broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string); - } - if (deletedBudgetItemId) { - broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string); - } - - res.json({ success: true }); - broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string); - - import('../services/notificationService').then(({ send }) => { - const tripInfo = db.prepare('SELECT title FROM trips WHERE id = ?').get(tripId) as { title: string } | undefined; - send({ event: 'booking_change', actorId: authReq.user.id, scope: 'trip', targetId: Number(tripId), params: { trip: tripInfo?.title || 'Untitled', actor: authReq.user.email, booking: reservation.title, type: reservation.type || 'booking', tripId: String(tripId) } }).catch(() => {}); - }); -}); - -export default router; diff --git a/server/src/routes/settings.ts b/server/src/routes/settings.ts deleted file mode 100644 index fb100c3d..00000000 --- a/server/src/routes/settings.ts +++ /dev/null @@ -1,36 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import * as settingsService from '../services/settingsService'; - -const router = express.Router(); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ settings: settingsService.getUserSettings(authReq.user.id) }); -}); - -router.put('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { key, value } = req.body; - if (!key) return res.status(400).json({ error: 'Key is required' }); - if (value === '••••••••') return res.json({ success: true, key, unchanged: true }); - settingsService.upsertSetting(authReq.user.id, key, value); - res.json({ success: true, key, value }); -}); - -router.post('/bulk', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { settings } = req.body; - if (!settings || typeof settings !== 'object') - return res.status(400).json({ error: 'Settings object is required' }); - try { - const updated = settingsService.bulkUpsertSettings(authReq.user.id, settings); - res.json({ success: true, updated }); - } catch (err) { - console.error('Error saving settings:', err); - res.status(500).json({ error: 'Error saving settings' }); - } -}); - -export default router; diff --git a/server/src/routes/share.ts b/server/src/routes/share.ts deleted file mode 100644 index 9e8145fd..00000000 --- a/server/src/routes/share.ts +++ /dev/null @@ -1,61 +0,0 @@ -import express, { Request, Response } from 'express'; -import { canAccessTrip } from '../db/database'; -import { authenticate } from '../middleware/auth'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import * as shareService from '../services/shareService'; - -const router = express.Router(); - -// Create a share link for a trip (owner/member only) -router.post('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const access = canAccessTrip(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const { share_map, share_bookings, share_packing, share_budget, share_collab } = req.body || {}; - const result = shareService.createOrUpdateShareLink(tripId, authReq.user.id, { - share_map, share_bookings, share_packing, share_budget, share_collab, - }); - - if (result.created) { - return res.status(201).json({ token: result.token }); - } - return res.json({ token: result.token }); -}); - -// Get share link status -router.get('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - if (!canAccessTrip(tripId, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - - const info = shareService.getShareLink(tripId); - res.json(info ? info : { token: null }); -}); - -// Delete share link -router.delete('/trips/:tripId/share-link', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const access = canAccessTrip(tripId, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - if (!checkPermission('share_manage', authReq.user.role, access.user_id, authReq.user.id, access.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - shareService.deleteShareLink(tripId); - res.json({ success: true }); -}); - -// Public read-only trip data (no auth required) -router.get('/shared/:token', (req: Request, res: Response) => { - const { token } = req.params; - const data = shareService.getSharedTripData(token); - if (!data) return res.status(404).json({ error: 'Invalid or expired link' }); - res.json(data); -}); - -export default router; diff --git a/server/src/routes/systemNotices.ts b/server/src/routes/systemNotices.ts deleted file mode 100644 index 2190963b..00000000 --- a/server/src/routes/systemNotices.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Router } from 'express'; -import { authenticate } from '../middleware/auth.js'; -import { getActiveNoticesFor, dismissNotice } from '../systemNotices/service.js'; -import type { AuthRequest } from '../types.js'; - -const router = Router(); - -// GET /api/system-notices/active -// Returns notices active for the authenticated user. -router.get('/active', authenticate, (req, res) => { - const userId = (req as AuthRequest).user!.id; - const notices = getActiveNoticesFor(userId); - res.json(notices); -}); - -// POST /api/system-notices/:id/dismiss -// Marks a notice as dismissed for the authenticated user. Idempotent. -router.post('/:id/dismiss', authenticate, (req, res) => { - const userId = (req as AuthRequest).user!.id; - const noticeId = req.params.id; - const ok = dismissNotice(userId, noticeId); - if (!ok) { - res.status(404).json({ error: 'NOTICE_NOT_FOUND' }); - return; - } - res.status(204).end(); -}); - -export default router; diff --git a/server/src/routes/tags.ts b/server/src/routes/tags.ts deleted file mode 100644 index 6087f7a7..00000000 --- a/server/src/routes/tags.ts +++ /dev/null @@ -1,38 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import * as tagService from '../services/tagService'; - -const router = express.Router(); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json({ tags: tagService.listTags(authReq.user.id) }); -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { name, color } = req.body; - if (!name) return res.status(400).json({ error: 'Tag name is required' }); - const tag = tagService.createTag(authReq.user.id, name, color); - res.status(201).json({ tag }); -}); - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { name, color } = req.body; - if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id)) - return res.status(404).json({ error: 'Tag not found' }); - const tag = tagService.updateTag(req.params.id, name, color); - res.json({ tag }); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!tagService.getTagByIdAndUser(req.params.id, authReq.user.id)) - return res.status(404).json({ error: 'Tag not found' }); - tagService.deleteTag(req.params.id); - res.json({ success: true }); -}); - -export default router; diff --git a/server/src/routes/todo.ts b/server/src/routes/todo.ts deleted file mode 100644 index b8fa6e77..00000000 --- a/server/src/routes/todo.ts +++ /dev/null @@ -1,127 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { checkPermission } from '../services/permissions'; -import { AuthRequest } from '../types'; -import { - verifyTripAccess, - listItems, - createItem, - updateItem, - deleteItem, - getCategoryAssignees, - updateCategoryAssignees, - reorderItems, -} from '../services/todoService'; - -const router = express.Router({ mergeParams: true }); - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const items = listItems(tripId); - res.json({ items }); -}); - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { name, category, due_date, description, assigned_user_id, priority } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!name) return res.status(400).json({ error: 'Item name is required' }); - - const item = createItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); - res.status(201).json({ item }); - broadcast(tripId, 'todo:created', { item }, req.headers['x-socket-id'] as string); -}); - -router.put('/reorder', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const { orderedIds } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - reorderItems(tripId, orderedIds); - res.json({ success: true }); -}); - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - const { name, checked, category, due_date, description, assigned_user_id, priority } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const updated = updateItem(tripId, id, { name, checked, category, due_date, description, assigned_user_id, priority }, Object.keys(req.body)); - if (!updated) return res.status(404).json({ error: 'Item not found' }); - - res.json({ item: updated }); - broadcast(tripId, 'todo:updated', { item: updated }, req.headers['x-socket-id'] as string); -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, id } = req.params; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - if (!deleteItem(tripId, id)) return res.status(404).json({ error: 'Item not found' }); - - res.json({ success: true }); - broadcast(tripId, 'todo:deleted', { itemId: Number(id) }, req.headers['x-socket-id'] as string); -}); - -// ── Category assignees ────────────────────────────────────────────────────── - -router.get('/category-assignees', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId } = req.params; - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const assignees = getCategoryAssignees(tripId); - res.json({ assignees }); -}); - -router.put('/category-assignees/:categoryName', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { tripId, categoryName } = req.params; - const { user_ids } = req.body; - - const trip = verifyTripAccess(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - if (!checkPermission('packing_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id)) - return res.status(403).json({ error: 'No permission' }); - - const cat = decodeURIComponent(categoryName); - const rows = updateCategoryAssignees(tripId, cat, user_ids); - - res.json({ assignees: rows }); - broadcast(tripId, 'todo:assignees', { category: cat, assignees: rows }, req.headers['x-socket-id'] as string); -}); - -export default router; diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts deleted file mode 100644 index 9b2413ba..00000000 --- a/server/src/routes/trips.ts +++ /dev/null @@ -1,358 +0,0 @@ -import express, { Request, Response } from 'express'; -import multer from 'multer'; -import path from 'path'; -import fs from 'fs'; -import { v4 as uuidv4 } from 'uuid'; -import { db, canAccessTrip } from '../db/database'; -import { authenticate, demoUploadBlock } from '../middleware/auth'; -import { broadcast } from '../websocket'; -import { AuthRequest, Trip } from '../types'; -import { writeAudit, getClientIp, logInfo } from '../services/auditLog'; -import { checkPermission } from '../services/permissions'; -import { - listTrips, - createTrip, - getTrip, - updateTrip, - deleteTrip, - getTripRaw, - getTripOwner, - deleteOldCover, - updateCoverImage, - listMembers, - addMember, - removeMember, - exportICS, - copyTripById, - verifyTripAccess, - NotFoundError, - ValidationError, - TRIP_SELECT, -} from '../services/tripService'; -import { listDays, listAccommodations } from '../services/dayService'; -import { listPlaces } from '../services/placeService'; -import { listItems as listPackingItems } from '../services/packingService'; -import { listItems as listTodoItems } from '../services/todoService'; -import { listBudgetItems } from '../services/budgetService'; -import { listReservations } from '../services/reservationService'; -import { listFiles } from '../services/fileService'; - -const router = express.Router(); - -const MAX_COVER_SIZE = 20 * 1024 * 1024; // 20 MB - -const coversDir = path.join(__dirname, '../../uploads/covers'); -const coverStorage = multer.diskStorage({ - destination: (_req, _file, cb) => { - if (!fs.existsSync(coversDir)) fs.mkdirSync(coversDir, { recursive: true }); - cb(null, coversDir); - }, - filename: (_req, file, cb) => { - const ext = path.extname(file.originalname); - cb(null, `${uuidv4()}${ext}`); - }, -}); -const uploadCover = multer({ - storage: coverStorage, - limits: { fileSize: MAX_COVER_SIZE }, - fileFilter: (_req, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; - if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) { - cb(null, true); - } else { - cb(new Error('Only jpg, png, gif, webp images allowed')); - } - }, -}); - -// ── List trips ──────────────────────────────────────────────────────────── - -router.get('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const archived = req.query.archived === '1' ? 1 : 0; - const trips = listTrips(authReq.user.id, archived); - res.json({ trips }); -}); - -// ── Create trip ─────────────────────────────────────────────────────────── - -router.post('/', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false)) - return res.status(403).json({ error: 'No permission to create trips' }); - - const { title, description, currency, reminder_days, day_count } = req.body; - if (!title) return res.status(400).json({ error: 'Title is required' }); - - const toDateStr = (d: Date) => d.toISOString().slice(0, 10); - const addDays = (d: Date, n: number) => { const r = new Date(d); r.setDate(r.getDate() + n); return r; }; - - let start_date: string | null = req.body.start_date || null; - let end_date: string | null = req.body.end_date || null; - - if (!start_date && !end_date) { - // No dates: create dateless placeholder days (day_count or default 7) - } else if (start_date && !end_date) { - end_date = toDateStr(addDays(new Date(start_date), 6)); - } else if (!start_date && end_date) { - start_date = toDateStr(addDays(new Date(end_date), -6)); - } - - if (start_date && end_date && new Date(end_date) < new Date(start_date)) - return res.status(400).json({ error: 'End date must be after start date' }); - - const parsedDayCount = day_count ? Math.min(Math.max(Number(day_count) || 7, 1), 365) : undefined; - const { trip, tripId, reminderDays } = createTrip(authReq.user.id, { title, description, start_date, end_date, currency, reminder_days, day_count: parsedDayCount }); - - writeAudit({ userId: authReq.user.id, action: 'trip.create', ip: getClientIp(req), details: { tripId, title, reminder_days: reminderDays === 0 ? 'none' : `${reminderDays} days` } }); - if (reminderDays > 0) { - logInfo(`${authReq.user.email} set ${reminderDays}-day reminder for trip "${title}"`); - } - - res.status(201).json({ trip }); -}); - -// ── Get trip ────────────────────────────────────────────────────────────── - -router.get('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const trip = getTrip(req.params.id, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - res.json({ trip }); -}); - -// ── Update trip ─────────────────────────────────────────────────────────── - -router.put('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const access = canAccessTrip(req.params.id, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - - const tripOwnerId = access.user_id; - const isMember = access.user_id !== authReq.user.id; - - // Archive check - if (req.body.is_archived !== undefined) { - if (!checkPermission('trip_archive', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) - return res.status(403).json({ error: 'No permission to archive/unarchive this trip' }); - } - // Cover image check - if (req.body.cover_image !== undefined) { - if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) - return res.status(403).json({ error: 'No permission to change cover image' }); - } - // General edit check (title, description, dates, currency, reminder_days) - const editFields = ['title', 'description', 'start_date', 'end_date', 'currency', 'reminder_days', 'day_count']; - if (editFields.some(f => req.body[f] !== undefined)) { - if (!checkPermission('trip_edit', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) - return res.status(403).json({ error: 'No permission to edit this trip' }); - } - - try { - const result = updateTrip(req.params.id, authReq.user.id, req.body, authReq.user.role); - - if (Object.keys(result.changes).length > 0) { - writeAudit({ userId: authReq.user.id, action: 'trip.update', ip: getClientIp(req), details: { tripId: Number(req.params.id), trip: result.newTitle, ...(result.ownerEmail ? { owner: result.ownerEmail } : {}), ...result.changes } }); - if (result.isAdminEdit && result.ownerEmail) { - logInfo(`Admin ${authReq.user.email} edited trip "${result.newTitle}" owned by ${result.ownerEmail}`); - } - } - - if (result.newReminder !== result.oldReminder) { - if (result.newReminder > 0) { - logInfo(`${authReq.user.email} set ${result.newReminder}-day reminder for trip "${result.newTitle}"`); - } else { - logInfo(`${authReq.user.email} removed reminder for trip "${result.newTitle}"`); - } - } - - res.json({ trip: result.updatedTrip }); - broadcast(req.params.id, 'trip:updated', { trip: result.updatedTrip }, req.headers['x-socket-id'] as string); - } catch (e: any) { - if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); - if (e instanceof ValidationError) return res.status(400).json({ error: e.message }); - throw e; - } -}); - -// ── Cover upload ────────────────────────────────────────────────────────── - -router.post('/:id/cover', authenticate, demoUploadBlock, uploadCover.single('cover'), (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const access = canAccessTrip(req.params.id, authReq.user.id); - const tripOwnerId = access?.user_id; - if (!tripOwnerId) return res.status(404).json({ error: 'Trip not found' }); - const isMember = tripOwnerId !== authReq.user.id; - if (!checkPermission('trip_cover_upload', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) - return res.status(403).json({ error: 'No permission to change the cover image' }); - - const trip = getTripRaw(req.params.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - if (!req.file) return res.status(400).json({ error: 'No image uploaded' }); - - deleteOldCover(trip.cover_image); - - const coverUrl = `/uploads/covers/${req.file.filename}`; - updateCoverImage(req.params.id, coverUrl); - res.json({ cover_image: coverUrl }); -}); - -// ── Copy / duplicate a trip ────────────────────────────────────────────────── -router.post('/:id/copy', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!checkPermission('trip_create', authReq.user.role, null, authReq.user.id, false)) - return res.status(403).json({ error: 'No permission to create trips' }); - - if (!canAccessTrip(req.params.id, authReq.user.id)) - return res.status(404).json({ error: 'Trip not found' }); - - try { - const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title); - writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } }); - const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId }); - res.status(201).json({ trip }); - } catch { - return res.status(500).json({ error: 'Failed to copy trip' }); - } -}); - -router.delete('/:id', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const tripOwner = getTripOwner(req.params.id); - if (!tripOwner) return res.status(404).json({ error: 'Trip not found' }); - const tripOwnerId = tripOwner.user_id; - const isMemberDel = tripOwnerId !== authReq.user.id; - if (!checkPermission('trip_delete', authReq.user.role, tripOwnerId, authReq.user.id, isMemberDel)) - return res.status(403).json({ error: 'No permission to delete this trip' }); - - const info = deleteTrip(req.params.id, authReq.user.id, authReq.user.role); - - writeAudit({ userId: authReq.user.id, action: 'trip.delete', ip: getClientIp(req), details: { tripId: info.tripId, trip: info.title, ...(info.ownerEmail ? { owner: info.ownerEmail } : {}) } }); - if (info.isAdminDelete && info.ownerEmail) { - logInfo(`Admin ${authReq.user.email} deleted trip "${info.title}" owned by ${info.ownerEmail}`); - } - - res.json({ success: true }); - broadcast(info.tripId, 'trip:deleted', { id: info.tripId }, req.headers['x-socket-id'] as string); -}); - -// ── List members ────────────────────────────────────────────────────────── - -router.get('/:id/members', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const access = canAccessTrip(req.params.id, authReq.user.id); - if (!access) - return res.status(404).json({ error: 'Trip not found' }); - - const { owner, members } = listMembers(req.params.id, access.user_id); - res.json({ owner, members, current_user_id: authReq.user.id }); -}); - -// ── Add member ──────────────────────────────────────────────────────────── - -router.post('/:id/members', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const access = canAccessTrip(req.params.id, authReq.user.id); - if (!access) - return res.status(404).json({ error: 'Trip not found' }); - - const tripOwnerId = access.user_id; - const isMember = tripOwnerId !== authReq.user.id; - if (!checkPermission('member_manage', authReq.user.role, tripOwnerId, authReq.user.id, isMember)) - return res.status(403).json({ error: 'No permission to manage members' }); - - const { identifier } = req.body; - - try { - const result = addMember(req.params.id, identifier, tripOwnerId, authReq.user.id); - - // Notify invited user - import('../services/notificationService').then(({ send }) => { - send({ event: 'trip_invite', actorId: authReq.user.id, scope: 'user', targetId: result.targetUserId, params: { trip: result.tripTitle, actor: authReq.user.email, invitee: result.member.email, tripId: String(req.params.id) } }).catch(() => {}); - }); - - res.status(201).json({ member: result.member }); - } catch (e: any) { - if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); - if (e instanceof ValidationError) return res.status(400).json({ error: e.message }); - throw e; - } -}); - -// ── Remove member ───────────────────────────────────────────────────────── - -router.delete('/:id/members/:userId', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.id, authReq.user.id)) - return res.status(404).json({ error: 'Trip not found' }); - - const targetId = parseInt(req.params.userId); - const isSelf = targetId === authReq.user.id; - if (!isSelf) { - const access = canAccessTrip(req.params.id, authReq.user.id); - if (!access) return res.status(404).json({ error: 'Trip not found' }); - const memberCheck = access.user_id !== authReq.user.id; - if (!checkPermission('member_manage', authReq.user.role, access.user_id, authReq.user.id, memberCheck)) - return res.status(403).json({ error: 'No permission to remove members' }); - } - - removeMember(req.params.id, targetId); - res.json({ success: true }); -}); - -// ── Offline bundle ──────────────────────────────────────────────────────── -// Returns all trip sub-collections in a single request for offline caching. - -router.get('/:id/bundle', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const tripId = req.params.id; - - const trip = getTrip(tripId, authReq.user.id); - if (!trip) return res.status(404).json({ error: 'Trip not found' }); - - const { days } = listDays(tripId); - const places = listPlaces(String(tripId), {}); - const packingItems = listPackingItems(tripId); - const todoItems = listTodoItems(tripId); - const budgetItems = listBudgetItems(tripId); - const reservations = listReservations(tripId); - const files = listFiles(tripId, false); - const accommodations = listAccommodations(tripId); - const { owner, members } = listMembers(tripId, trip.user_id); - const allMembers = [owner, ...(members || [])].filter(Boolean); - - res.json({ - trip, - days, - places, - packingItems, - todoItems, - budgetItems, - reservations, - files, - accommodations, - members: allMembers, - }); -}); - -// ── ICS calendar export ─────────────────────────────────────────────────── - -router.get('/:id/export.ics', authenticate, (req: Request, res: Response) => { - const authReq = req as AuthRequest; - if (!canAccessTrip(req.params.id, authReq.user.id)) - return res.status(404).json({ error: 'Trip not found' }); - - try { - const { ics, filename } = exportICS(req.params.id); - - res.setHeader('Content-Type', 'text/calendar; charset=utf-8'); - res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); - res.send(ics); - } catch (e: any) { - if (e instanceof NotFoundError) return res.status(404).json({ error: e.message }); - throw e; - } -}); - -export default router; diff --git a/server/src/routes/vacay.ts b/server/src/routes/vacay.ts deleted file mode 100644 index e3ad5280..00000000 --- a/server/src/routes/vacay.ts +++ /dev/null @@ -1,194 +0,0 @@ -import express, { Request, Response } from 'express'; -import { authenticate } from '../middleware/auth'; -import { AuthRequest } from '../types'; -import * as svc from '../services/vacayService'; - -const router = express.Router(); -router.use(authenticate); - -router.get('/plan', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - res.json(svc.getPlanData(authReq.user.id)); -}); - -router.put('/plan', async (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - const result = await svc.updatePlan(planId, req.body, req.headers['x-socket-id'] as string); - res.json(result); -}); - -router.post('/plan/holiday-calendars', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { region, label, color, sort_order } = req.body; - if (!region) return res.status(400).json({ error: 'region required' }); - const planId = svc.getActivePlanId(authReq.user.id); - const calendar = svc.addHolidayCalendar(planId, region, label, color, sort_order, req.headers['x-socket-id'] as string); - res.json({ calendar }); -}); - -router.put('/plan/holiday-calendars/:id', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - const planId = svc.getActivePlanId(authReq.user.id); - const calendar = svc.updateHolidayCalendar(id, planId, req.body, req.headers['x-socket-id'] as string); - if (!calendar) return res.status(404).json({ error: 'Calendar not found' }); - res.json({ calendar }); -}); - -router.delete('/plan/holiday-calendars/:id', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const id = parseInt(req.params.id); - const planId = svc.getActivePlanId(authReq.user.id); - const deleted = svc.deleteHolidayCalendar(id, planId, req.headers['x-socket-id'] as string); - if (!deleted) return res.status(404).json({ error: 'Calendar not found' }); - res.json({ success: true }); -}); - -router.put('/color', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { color, target_user_id } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = svc.getPlanUsers(planId); - if (!planUsers.find(u => u.id === userId)) { - return res.status(403).json({ error: 'User not in plan' }); - } - svc.setUserColor(userId, planId, color, req.headers['x-socket-id'] as string); - res.json({ success: true }); -}); - -router.post('/invite', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { user_id } = req.body; - if (!user_id) return res.status(400).json({ error: 'user_id required' }); - const plan = svc.getActivePlan(authReq.user.id); - const result = svc.sendInvite(plan.id, authReq.user.id, authReq.user.username, authReq.user.email, user_id); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -router.post('/invite/accept', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { plan_id } = req.body; - const result = svc.acceptInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); - if (result.error) return res.status(result.status!).json({ error: result.error }); - res.json({ success: true }); -}); - -router.post('/invite/decline', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { plan_id } = req.body; - svc.declineInvite(authReq.user.id, plan_id, req.headers['x-socket-id'] as string); - res.json({ success: true }); -}); - -router.post('/invite/cancel', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { user_id } = req.body; - const plan = svc.getActivePlan(authReq.user.id); - svc.cancelInvite(plan.id, user_id); - res.json({ success: true }); -}); - -router.post('/dissolve', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - svc.dissolvePlan(authReq.user.id, req.headers['x-socket-id'] as string); - res.json({ success: true }); -}); - -router.get('/available-users', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - const users = svc.getAvailableUsers(authReq.user.id, planId); - res.json({ users }); -}); - -router.get('/years', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - res.json({ years: svc.listYears(planId) }); -}); - -router.post('/years', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { year } = req.body; - if (!year) return res.status(400).json({ error: 'Year required' }); - const planId = svc.getActivePlanId(authReq.user.id); - const years = svc.addYear(planId, year, req.headers['x-socket-id'] as string); - res.json({ years }); -}); - -router.delete('/years/:year', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const year = parseInt(req.params.year); - const planId = svc.getActivePlanId(authReq.user.id); - const years = svc.deleteYear(planId, year, req.headers['x-socket-id'] as string); - res.json({ years }); -}); - -router.get('/entries/:year', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const planId = svc.getActivePlanId(authReq.user.id); - res.json(svc.getEntries(planId, req.params.year)); -}); - -router.post('/entries/toggle', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { date, target_user_id } = req.body; - if (!date) return res.status(400).json({ error: 'date required' }); - const planId = svc.getActivePlanId(authReq.user.id); - let userId = authReq.user.id; - if (target_user_id && parseInt(target_user_id) !== authReq.user.id) { - const planUsers = svc.getPlanUsers(planId); - const tid = parseInt(target_user_id); - if (!planUsers.find(u => u.id === tid)) { - return res.status(403).json({ error: 'User not in plan' }); - } - userId = tid; - } - res.json(svc.toggleEntry(userId, planId, date, req.headers['x-socket-id'] as string)); -}); - -router.post('/entries/company-holiday', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const { date, note } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - res.json(svc.toggleCompanyHoliday(planId, date, note, req.headers['x-socket-id'] as string)); -}); - -router.get('/stats/:year', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const year = parseInt(req.params.year); - const planId = svc.getActivePlanId(authReq.user.id); - res.json({ stats: svc.getStats(planId, year) }); -}); - -router.put('/stats/:year', (req: Request, res: Response) => { - const authReq = req as AuthRequest; - const year = parseInt(req.params.year); - const { vacation_days, target_user_id } = req.body; - const planId = svc.getActivePlanId(authReq.user.id); - const userId = target_user_id ? parseInt(target_user_id) : authReq.user.id; - const planUsers = svc.getPlanUsers(planId); - if (!planUsers.find(u => u.id === userId)) { - return res.status(403).json({ error: 'User not in plan' }); - } - svc.updateStats(userId, planId, year, vacation_days, req.headers['x-socket-id'] as string); - res.json({ success: true }); -}); - -router.get('/holidays/countries', async (_req: Request, res: Response) => { - const result = await svc.getCountries(); - if (result.error) return res.status(502).json({ error: result.error }); - res.json(result.data); -}); - -router.get('/holidays/:year/:country', async (req: Request, res: Response) => { - const { year, country } = req.params; - const result = await svc.getHolidays(year, country); - if (result.error) return res.status(502).json({ error: result.error }); - res.json(result.data); -}); - -export default router; diff --git a/server/src/services/fileService.ts b/server/src/services/fileService.ts index 8a479ba6..2e7f9d75 100644 --- a/server/src/services/fileService.ts +++ b/server/src/services/fileService.ts @@ -113,10 +113,6 @@ export function getFileById(id: string | number, tripId: string | number): TripF return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId) as TripFile | undefined; } -export function getFileByIdFull(id: string | number): TripFile { - return db.prepare(`${FILE_SELECT} WHERE f.id = ?`).get(id) as TripFile; -} - export function getDeletedFile(id: string | number, tripId: string | number): TripFile | undefined { return db.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ? AND deleted_at IS NOT NULL').get(id, tripId) as TripFile | undefined; } diff --git a/server/src/services/journeyService.ts b/server/src/services/journeyService.ts index b092084e..3491706a 100644 --- a/server/src/services/journeyService.ts +++ b/server/src/services/journeyService.ts @@ -1,4 +1,4 @@ -import { db } from '../db/database'; +import { db, canAccessTrip } from '../db/database'; import { broadcastToUser } from '../websocket'; import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../types'; import { getOrCreateTrekPhoto, getOrCreateLocalTrekPhoto, setTrekPhotoProvider, deleteTrekPhotoIfOrphan } from './memories/photoResolverService'; @@ -254,6 +254,11 @@ export function deleteJourney(journeyId: number, userId: number): boolean { // ── Trip management ────────────────────────────────────────────────────── export function addTripToJourney(journeyId: number, tripId: number, userId: number): boolean { + // Only attach a trip the caller can actually access — otherwise a journey + // owner could pull an arbitrary trip's places + photos into their journey + // (cross-tenant leak). Mirrors the trip-access gate every other trip-scoped + // path enforces. + if (!canAccessTrip(tripId, userId)) return false; const now = ts(); try { db.prepare( diff --git a/server/tests/e2e/addons.e2e.test.ts b/server/tests/e2e/addons.e2e.test.ts new file mode 100644 index 00000000..3a7c6309 --- /dev/null +++ b/server/tests/e2e/addons.e2e.test.ts @@ -0,0 +1,107 @@ +/** + * GET /api/addons e2e — exercises the AddonsController through the real + * JwtAuthGuard against a temp SQLite db. getCollabFeatures + getPhotoProviderConfig + * are mocked; the addons/photo_providers/photo_provider_fields reads run against + * the temp db. Asserts the byte-identical body the legacy inline handler produced. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + tmp.exec(`CREATE TABLE addons (id TEXT PRIMARY KEY, name TEXT, type TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`); + tmp.exec(`CREATE TABLE photo_providers (id TEXT PRIMARY KEY, name TEXT, icon TEXT, enabled INTEGER, sort_order INTEGER);`); + tmp.exec(`CREATE TABLE photo_provider_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, provider_id TEXT, field_key TEXT, + label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, + settings_key TEXT, payload_key TEXT, sort_order INTEGER);`); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ + db, canAccessTrip: vi.fn(), isOwner: vi.fn(), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, +})); + +const { getCollabFeatures, getPhotoProviderConfig } = vi.hoisted(() => ({ + getCollabFeatures: vi.fn(() => ({ chat: true, notes: true, polls: true, whatsnext: true })), + getPhotoProviderConfig: vi.fn(() => ({ url: 'https://immich.example' })), +})); +vi.mock('../../src/services/adminService', () => ({ getCollabFeatures })); +vi.mock('../../src/services/memories/helpersService', () => ({ getPhotoProviderConfig })); + +import { AddonsModule } from '../../src/nest/addons/addons.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +describe('GET /api/addons e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [AddonsModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('packing','Packing','trip','Backpack',1,1)").run(); + db.prepare("INSERT INTO addons (id, name, type, icon, enabled, sort_order) VALUES ('disabled','Disabled','trip','X',0,2)").run(); + db.prepare("INSERT INTO photo_providers (id, name, icon, enabled, sort_order) VALUES ('immich','Immich','Image',1,1)").run(); + db.prepare(`INSERT INTO photo_provider_fields (provider_id, field_key, label, input_type, placeholder, hint, required, secret, settings_key, payload_key, sort_order) + VALUES ('immich','base_url','Base URL','text','https://...',NULL,1,0,'immich_url',NULL,1)`).run(); + app = await build(); + server = app.getHttpServer(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('401 without a cookie', async () => { + expect((await request(server).get('/api/addons')).status).toBe(401); + }); + + it('200 returns enabled addons + photo providers (disabled addon excluded)', async () => { + const res = await request(server).get('/api/addons').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true }, + addons: [ + { id: 'packing', name: 'Packing', type: 'trip', icon: 'Backpack', enabled: true }, + { + id: 'immich', + name: 'Immich', + type: 'photo_provider', + icon: 'Image', + enabled: true, + config: { url: 'https://immich.example' }, + fields: [ + { + key: 'base_url', + label: 'Base URL', + input_type: 'text', + placeholder: 'https://...', + hint: null, + required: true, + secret: false, + settings_key: 'immich_url', + payload_key: null, + sort_order: 1, + }, + ], + }, + ], + }); + }); +}); diff --git a/server/tests/e2e/memories.e2e.test.ts b/server/tests/e2e/memories.e2e.test.ts new file mode 100644 index 00000000..708fd837 --- /dev/null +++ b/server/tests/e2e/memories.e2e.test.ts @@ -0,0 +1,411 @@ +/** + * Memories (photo-providers) module e2e — exercises the migrated + * /api/integrations/memories endpoints (unified + immich + synologyphotos) + * through the real JwtAuthGuard against a temp SQLite db. The provider services + * and canAccessUserPhoto are mocked; fail/success stay real so the envelope + * shapes are produced by the actual helper code. + * + * Focus: auth (401), every route's happy path, the CRITICAL 200-on-failure + * behaviour of /test + /status, and at least one error envelope per provider + * router — all asserted byte-identical to the legacy Express routers. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import type { Server } from 'http'; +import { Test } from '@nestjs/testing'; +import { seedUser, sessionCookie } from './harness'; + +const { db } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const tmp = new Database(':memory:'); + tmp.exec('PRAGMA journal_mode = WAL'); + tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, + email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); + return { db: tmp }; +}); + +vi.mock('../../src/db/database', () => ({ db, canAccessTrip: vi.fn(), closeDb: () => {}, reinitialize: () => {} })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); + +// Provider services — fully mocked. fail/success/canAccessUserPhoto from the +// helper module are kept real except canAccessUserPhoto which we override. +const { unified, immich, synology } = vi.hoisted(() => ({ + unified: { + listTripPhotos: vi.fn(), addTripPhotos: vi.fn(), setTripPhotoSharing: vi.fn(), + removeTripPhoto: vi.fn(), listTripAlbumLinks: vi.fn(), createTripAlbumLink: vi.fn(), removeAlbumLink: vi.fn(), + }, + immich: { + getConnectionSettings: vi.fn(), saveImmichSettings: vi.fn(), setImmichAutoUpload: vi.fn(), + testConnection: vi.fn(), getConnectionStatus: vi.fn(), browseTimeline: vi.fn(), searchPhotos: vi.fn(), + streamImmichAsset: vi.fn(), listAlbums: vi.fn(), getAlbumPhotos: vi.fn(), syncAlbumAssets: vi.fn(), + getAssetInfo: vi.fn(), isValidAssetId: vi.fn(), + }, + synology: { + getSynologySettings: vi.fn(), updateSynologySettings: vi.fn(), getSynologyStatus: vi.fn(), + testSynologyConnection: vi.fn(), listSynologyAlbums: vi.fn(), getSynologyAlbumPhotos: vi.fn(), + syncSynologyAlbumLink: vi.fn(), searchSynologyPhotos: vi.fn(), getSynologyAssetInfo: vi.fn(), + streamSynologyAsset: vi.fn(), + }, +})); +vi.mock('../../src/services/memories/unifiedService', () => unified); +vi.mock('../../src/services/memories/immichService', () => immich); +vi.mock('../../src/services/memories/synologyService', () => synology); + +const { canAccessUserPhoto } = vi.hoisted(() => ({ canAccessUserPhoto: vi.fn() })); +vi.mock('../../src/services/memories/helpersService', async () => { + const actual = await vi.importActual( + '../../src/services/memories/helpersService', + ); + return { ...actual, canAccessUserPhoto }; +}); + +import { MemoriesModule } from '../../src/nest/memories/memories.module'; +import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; + +const BASE = '/api/integrations/memories'; +const UNIFIED = `${BASE}/unified`; +const IMMICH = `${BASE}/immich`; +const SYNO = `${BASE}/synologyphotos`; + +describe('Memories e2e (real auth guard + temp SQLite)', () => { + let server: Server; + let app: Awaited>; + + async function build() { + const moduleRef = await Test.createTestingModule({ imports: [MemoriesModule] }).compile(); + const nest = moduleRef.createNestApplication(); + nest.use(cookieParser()); + nest.useGlobalFilters(new TrekExceptionFilter()); + await nest.init(); + return nest; + } + + beforeAll(async () => { + seedUser(db as never, { id: 1 }); + app = await build(); + server = app.getHttpServer(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + canAccessUserPhoto.mockReturnValue(true); + immich.isValidAssetId.mockReturnValue(true); + }); + + // ── Auth ─────────────────────────────────────────────────────────────────── + describe('auth', () => { + it('401 without a cookie (unified photos)', async () => { + expect((await request(server).get(`${UNIFIED}/trips/5/photos`)).status).toBe(401); + }); + it('401 without a cookie (immich status)', async () => { + expect((await request(server).get(`${IMMICH}/status`)).status).toBe(401); + }); + it('401 without a cookie (synology albums)', async () => { + expect((await request(server).get(`${SYNO}/albums`)).status).toBe(401); + }); + }); + + // ── Unified ────────────────────────────────────────────────────────────────── + describe('unified', () => { + it('200 list photos -> { photos }', async () => { + unified.listTripPhotos.mockReturnValue({ success: true, data: [{ photo_id: 1, asset_id: 'a' }] }); + const res = await request(server).get(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ photos: [{ photo_id: 1, asset_id: 'a' }] }); + }); + + it('200 add photos -> { success, added } (POST stays 200, not 201)', async () => { + unified.addTripPhotos.mockResolvedValue({ success: true, data: { added: 2, shared: true } }); + const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)) + .send({ shared: true, selections: [{ provider: 'immich', asset_ids: ['a', 'b'] }] }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, added: 2 }); + // x-socket-id absent -> undefined, matching the legacy `req.headers['x-socket-id'] as string`. + expect(unified.addTripPhotos).toHaveBeenCalledWith('5', 1, true, [{ provider: 'immich', asset_ids: ['a', 'b'] }], undefined); + }); + + it('400 add photos with empty selections -> error envelope', async () => { + unified.addTripPhotos.mockResolvedValue({ success: false, error: { message: 'No photos selected', status: 400 } }); + const res = await request(server).post(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ selections: [] }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'No photos selected' }); + }); + + it('200 PUT sharing -> { success: true }', async () => { + unified.setTripPhotoSharing.mockResolvedValue({ success: true, data: true }); + const res = await request(server).put(`${UNIFIED}/trips/5/photos/sharing`).set('Cookie', sessionCookie(1)).send({ photo_id: 9, shared: true }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + }); + + it('404 DELETE photo on inaccessible trip -> error envelope', async () => { + unified.removeTripPhoto.mockReturnValue({ success: false, error: { message: 'Trip not found or access denied', status: 404 } }); + const res = await request(server).delete(`${UNIFIED}/trips/5/photos`).set('Cookie', sessionCookie(1)).send({ photo_id: 9 }); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'Trip not found or access denied' }); + }); + + it('200 list album-links -> { links }', async () => { + unified.listTripAlbumLinks.mockReturnValue({ success: true, data: [{ id: 'l1' }] }); + const res = await request(server).get(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ links: [{ id: 'l1' }] }); + }); + + it('200 create album-link / 409 duplicate envelope', async () => { + unified.createTripAlbumLink.mockReturnValue({ success: true, data: true }); + const ok = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' }); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ success: true }); + + unified.createTripAlbumLink.mockReturnValue({ success: false, error: { message: 'Album already linked', status: 409 } }); + const dup = await request(server).post(`${UNIFIED}/trips/5/album-links`).set('Cookie', sessionCookie(1)).send({ provider: 'immich', album_id: 'al', album_name: 'A' }); + expect(dup.status).toBe(409); + expect(dup.body).toEqual({ error: 'Album already linked' }); + }); + + it('200 DELETE album-link -> { success: true }', async () => { + unified.removeAlbumLink.mockReturnValue({ success: true, data: true }); + const res = await request(server).delete(`${UNIFIED}/trips/5/album-links/7`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true }); + }); + }); + + // ── Immich ─────────────────────────────────────────────────────────────────── + describe('immich', () => { + it('200 settings', async () => { + immich.getConnectionSettings.mockReturnValue({ immich_url: '', connected: false, auto_upload: false }); + const res = await request(server).get(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ immich_url: '', connected: false, auto_upload: false }); + }); + + it('200 PUT settings success / 400 invalid url', async () => { + immich.saveImmichSettings.mockResolvedValue({ success: true }); + const ok = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k', auto_upload: true }); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ success: true }); + expect(immich.setImmichAutoUpload).toHaveBeenCalledWith(1, true); + + immich.saveImmichSettings.mockResolvedValue({ success: false, error: 'Invalid Immich URL: bad' }); + const bad = await request(server).put(`${IMMICH}/settings`).set('Cookie', sessionCookie(1)).send({ immich_url: 'bad' }); + expect(bad.status).toBe(400); + expect(bad.body).toEqual({ error: 'Invalid Immich URL: bad' }); + }); + + it('CRITICAL: 200 /status with { connected: false } on failure', async () => { + immich.getConnectionStatus.mockResolvedValue({ connected: false, error: 'Not configured' }); + const res = await request(server).get(`${IMMICH}/status`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: false, error: 'Not configured' }); + }); + + it('CRITICAL: 200 /test missing fields -> { connected: false, error } without calling service', async () => { + const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: false, error: 'URL and API key required' }); + expect(immich.testConnection).not.toHaveBeenCalled(); + }); + + it('200 /test with creds delegates to service', async () => { + immich.testConnection.mockResolvedValue({ connected: true, user: { name: 'T' } }); + const res = await request(server).post(`${IMMICH}/test`).set('Cookie', sessionCookie(1)).send({ immich_url: 'https://x', immich_api_key: 'k' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: true, user: { name: 'T' } }); + }); + + it('200 browse / 400 not configured', async () => { + immich.browseTimeline.mockResolvedValue({ buckets: [{ count: 3 }] }); + const ok = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ buckets: [{ count: 3 }] }); + + immich.browseTimeline.mockResolvedValue({ error: 'Immich not configured', status: 400 }); + const bad = await request(server).get(`${IMMICH}/browse`).set('Cookie', sessionCookie(1)); + expect(bad.status).toBe(400); + expect(bad.body).toEqual({ error: 'Immich not configured' }); + }); + + it('200 search (POST stays 200) / 502 envelope', async () => { + immich.searchPhotos.mockResolvedValue({ assets: [{ id: 'a' }], hasMore: true }); + const ok = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({ page: 1, size: 50 }); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ assets: [{ id: 'a' }], hasMore: true }); + expect(immich.searchPhotos).toHaveBeenCalledWith(1, undefined, undefined, 1, 50); + + immich.searchPhotos.mockResolvedValue({ error: 'Could not reach Immich', status: 502 }); + const bad = await request(server).post(`${IMMICH}/search`).set('Cookie', sessionCookie(1)).send({}); + expect(bad.status).toBe(502); + expect(bad.body).toEqual({ error: 'Could not reach Immich' }); + }); + + it('200 asset info / 400 invalid id / 403 no access', async () => { + immich.getAssetInfo.mockResolvedValue({ data: { id: 'asset-1', city: 'Paris' } }); + const ok = await request(server).get(`${IMMICH}/assets/5/asset-1/1/info`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ id: 'asset-1', city: 'Paris' }); + + immich.isValidAssetId.mockReturnValue(false); + const invalid = await request(server).get(`${IMMICH}/assets/5/bad/1/info`).set('Cookie', sessionCookie(1)); + expect(invalid.status).toBe(400); + expect(invalid.body).toEqual({ error: 'Invalid asset ID' }); + + immich.isValidAssetId.mockReturnValue(true); + canAccessUserPhoto.mockReturnValue(false); + const forbidden = await request(server).get(`${IMMICH}/assets/5/asset-1/2/info`).set('Cookie', sessionCookie(1)); + expect(forbidden.status).toBe(403); + expect(forbidden.body).toEqual({ error: 'Forbidden' }); + }); + + it('streams thumbnail bytes via the service helper', async () => { + immich.streamImmichAsset.mockImplementation(async (res: any) => { + res.status(200); + res.set('Content-Type', 'image/webp'); + res.end(Buffer.from('thumb-bytes')); + }); + const res = await request(server).get(`${IMMICH}/assets/5/asset-1/1/thumbnail`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toContain('image/webp'); + expect(immich.streamImmichAsset).toHaveBeenCalledWith(expect.anything(), 1, 'asset-1', 'thumbnail', 1); + }); + + it('200 albums / 200 album photos', async () => { + immich.listAlbums.mockResolvedValue({ albums: [{ id: 'al' }] }); + const albums = await request(server).get(`${IMMICH}/albums`).set('Cookie', sessionCookie(1)); + expect(albums.status).toBe(200); + expect(albums.body).toEqual({ albums: [{ id: 'al' }] }); + + immich.getAlbumPhotos.mockResolvedValue({ assets: [{ id: 'p1' }] }); + const photos = await request(server).get(`${IMMICH}/albums/al/photos`).set('Cookie', sessionCookie(1)); + expect(photos.status).toBe(200); + expect(photos.body).toEqual({ assets: [{ id: 'p1' }] }); + }); + + it('200 album sync (POST stays 200) / 404 envelope', async () => { + immich.syncAlbumAssets.mockResolvedValue({ success: true, added: 3, total: 10 }); + const ok = await request(server).post(`${IMMICH}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ success: true, added: 3, total: 10 }); + + immich.syncAlbumAssets.mockResolvedValue({ error: 'Album link not found', status: 404 }); + const bad = await request(server).post(`${IMMICH}/trips/5/album-links/9/sync`).set('Cookie', sessionCookie(1)); + expect(bad.status).toBe(404); + expect(bad.body).toEqual({ error: 'Album link not found' }); + }); + }); + + // ── Synology ─────────────────────────────────────────────────────────────── + describe('synologyphotos', () => { + it('200 settings', async () => { + synology.getSynologySettings.mockResolvedValue({ success: true, data: { synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true } }); + const res = await request(server).get(`${SYNO}/settings`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ synology_url: 'u', synology_username: 'n', synology_skip_ssl: true, connected: true }); + }); + + it('400 PUT settings without url/username -> envelope', async () => { + const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: '' }); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'URL and username are required' }); + expect(synology.updateSynologySettings).not.toHaveBeenCalled(); + }); + + it('200 PUT settings delegates when valid', async () => { + synology.updateSynologySettings.mockResolvedValue({ success: true, data: 'settings updated' }); + const res = await request(server).put(`${SYNO}/settings`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' }); + expect(res.status).toBe(200); + expect(res.body).toEqual('settings updated'); + }); + + it('CRITICAL: 200 /status with { connected: false } on failure', async () => { + synology.getSynologyStatus.mockResolvedValue({ success: true, data: { connected: false, error: 'Synology not configured' } }); + const res = await request(server).get(`${SYNO}/status`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: false, error: 'Synology not configured' }); + }); + + it('CRITICAL: 200 /test missing fields -> 200 { connected: false, error } without calling service', async () => { + const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: false, error: 'Username, Password are required' }); + expect(synology.testSynologyConnection).not.toHaveBeenCalled(); + }); + + it('200 /test delegates when all fields present', async () => { + synology.testSynologyConnection.mockResolvedValue({ success: true, data: { connected: true, user: { name: 'admin' } } }); + const res = await request(server).post(`${SYNO}/test`).set('Cookie', sessionCookie(1)).send({ synology_url: 'https://nas', synology_username: 'admin', synology_password: 'pw' }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ connected: true, user: { name: 'admin' } }); + }); + + it('200 albums / 200 album photos with passphrase', async () => { + synology.listSynologyAlbums.mockResolvedValue({ success: true, data: { albums: [{ id: '1', albumName: 'A', assetCount: 3 }] } }); + const albums = await request(server).get(`${SYNO}/albums`).set('Cookie', sessionCookie(1)); + expect(albums.status).toBe(200); + expect(albums.body).toEqual({ albums: [{ id: '1', albumName: 'A', assetCount: 3 }] }); + + synology.getSynologyAlbumPhotos.mockResolvedValue({ success: true, data: { assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false } }); + const photos = await request(server).get(`${SYNO}/albums/1/photos?passphrase=secret`).set('Cookie', sessionCookie(1)); + expect(photos.status).toBe(200); + expect(photos.body).toEqual({ assets: [{ id: 'p', takenAt: '' }], total: 1, hasMore: false }); + expect(synology.getSynologyAlbumPhotos).toHaveBeenCalledWith(1, '1', 'secret'); + }); + + it('200 search (POST stays 200) with offset/limit coercion', async () => { + synology.searchSynologyPhotos.mockResolvedValue({ success: true, data: { assets: [], total: 0, hasMore: false } }); + const res = await request(server).post(`${SYNO}/search`).set('Cookie', sessionCookie(1)).send({ page: 3, size: 20 }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ assets: [], total: 0, hasMore: false }); + // page=3 -> (3-1)=2; size=20 -> limit=20; offset = 2 * 20 = 40 + expect(synology.searchSynologyPhotos).toHaveBeenCalledWith(1, undefined, undefined, 40, 20); + }); + + it('200 album sync (POST stays 200)', async () => { + synology.syncSynologyAlbumLink.mockResolvedValue({ success: true, data: { added: 2, total: 5 } }); + const res = await request(server).post(`${SYNO}/trips/5/album-links/7/sync`).set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ added: 2, total: 5 }); + }); + + it('200 asset info / 403 distinct synology string on no access', async () => { + synology.getSynologyAssetInfo.mockResolvedValue({ success: true, data: { id: '40808_1', takenAt: null } }); + const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/info`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.body).toEqual({ id: '40808_1', takenAt: null }); + + canAccessUserPhoto.mockReturnValue(false); + const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/info`).set('Cookie', sessionCookie(1)); + expect(forbidden.status).toBe(403); + expect(forbidden.body).toEqual({ error: "You don't have access to this photo" }); + }); + + it('400 invalid asset kind / 403 no access / stream on valid kind', async () => { + const invalid = await request(server).get(`${SYNO}/assets/5/40808_1/1/bogus`).set('Cookie', sessionCookie(1)); + expect(invalid.status).toBe(400); + expect(invalid.body).toEqual({ error: 'Invalid asset kind' }); + + canAccessUserPhoto.mockReturnValue(false); + const forbidden = await request(server).get(`${SYNO}/assets/5/40808_1/2/thumbnail`).set('Cookie', sessionCookie(1)); + expect(forbidden.status).toBe(403); + expect(forbidden.body).toEqual({ error: "You don't have access to this photo" }); + + canAccessUserPhoto.mockReturnValue(true); + synology.streamSynologyAsset.mockImplementation(async (res: any) => { + res.status(200); + res.set('Content-Type', 'image/jpeg'); + res.end(Buffer.from('syno-bytes')); + }); + const ok = await request(server).get(`${SYNO}/assets/5/40808_1/1/thumbnail?size=xl`).set('Cookie', sessionCookie(1)); + expect(ok.status).toBe(200); + expect(ok.headers['content-type']).toContain('image/jpeg'); + expect(synology.streamSynologyAsset).toHaveBeenCalledWith(expect.anything(), 1, 1, '40808_1', 'thumbnail', 'xl', undefined); + }); + }); +}); diff --git a/server/tests/e2e/reservations.e2e.test.ts b/server/tests/e2e/reservations.e2e.test.ts index 311cfee5..3261c26b 100644 --- a/server/tests/e2e/reservations.e2e.test.ts +++ b/server/tests/e2e/reservations.e2e.test.ts @@ -35,7 +35,7 @@ vi.mock('../../src/services/permissions', () => ({ checkPermission })); const { resv, budget, day } = vi.hoisted(() => ({ resv: { verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(), - getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), + getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), getUpcomingReservations: vi.fn(), }, budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() }, day: { @@ -72,6 +72,7 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', () day.listAccommodations.mockReturnValue([{ id: 1 }]); day.validateAccommodationRefs.mockReturnValue([]); day.createAccommodation.mockReturnValue({ id: 9 }); + resv.getUpcomingReservations.mockReturnValue([{ id: 1, trip_id: 5, title: 'Flight' }]); }); beforeEach(() => { @@ -94,6 +95,16 @@ describe('Reservations + accommodations e2e (real auth guard + temp SQLite)', () expect(res.body).toEqual({ reservations: [{ id: 1, title: 'Hotel' }] }); }); + it('401 without a cookie (upcoming feed)', async () => { + expect((await request(server).get('/api/reservations/upcoming')).status).toBe(401); + }); + + it('200 cross-trip upcoming reservations feed', async () => { + const res = await request(server).get('/api/reservations/upcoming').set('Cookie', sessionCookie(1)); + expect(res.status).toBe(200); + expect(res.body).toEqual({ reservations: [{ id: 1, trip_id: 5, title: 'Flight' }] }); + }); + it('404 when trip not accessible (reservations)', async () => { resv.verifyTripAccess.mockReturnValue(undefined); const res = await request(server).get('/api/trips/5/reservations').set('Cookie', sessionCookie(1)); diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index d09497b0..806e8d01 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -17,8 +17,11 @@ */ import Database from 'better-sqlite3'; +import type { INestApplication } from '@nestjs/common'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; +import { AuthPublicController } from '../../src/nest/auth/auth-public.controller'; +import type { RateLimitService } from '../../src/nest/auth/rate-limit.service'; // Tables to clear on reset, child-before-parent to be safe (FK checks are OFF during reset). // Keep in sync with schema.ts + migrations.ts. Intentionally excluded: categories, addons, @@ -238,6 +241,22 @@ export function buildDbMock(testDb: Database.Database) { }; } +/** + * Resets the Nest per-IP rate-limit buckets between tests — the buildApp() drop-in + * for the legacy `loginAttempts.clear(); mfaAttempts.clear()`. + * + * The Nest auth path keeps its rate-limit state in a RateLimitService instance that + * lives inside the AuthModule injector (shared by AuthPublicController/AuthController + * for the login/mfa/forgot buckets). The same class is ALSO provided separately in + * OauthModule (its own instance, distinct oauth_* buckets), so a plain + * app.get(RateLimitService) is ambiguous and may hand back the wrong instance — we + * resolve the auth controller and clear the limiter it actually uses. + */ +export function resetRateLimits(app: INestApplication): void { + const ctrl = app.get(AuthPublicController, { strict: false }) as unknown as { rl: RateLimitService }; + ctrl.rl.reset(); +} + /** Fixed config mock — use with vi.mock('../../src/config', () => TEST_CONFIG) */ export const TEST_CONFIG = { JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', diff --git a/server/tests/integration/admin.test.ts b/server/tests/integration/admin.test.ts index e96d2234..7ac70848 100644 --- a/server/tests/integration/admin.test.ts +++ b/server/tests/integration/admin.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/assignments.test.ts b/server/tests/integration/assignments.test.ts index 620c95e7..06c323c6 100644 --- a/server/tests/integration/assignments.test.ts +++ b/server/tests/integration/assignments.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/atlas.test.ts b/server/tests/integration/atlas.test.ts index da6d25d8..c99c199b 100644 --- a/server/tests/integration/atlas.test.ts +++ b/server/tests/integration/atlas.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/auth.test.ts b/server/tests/integration/auth.test.ts index 2eee1f97..6387103e 100644 --- a/server/tests/integration/auth.test.ts +++ b/server/tests/integration/auth.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import { authenticator } from 'otplib'; // ───────────────────────────────────────────────────────────────────────────── @@ -46,31 +47,35 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories'; import { authCookie, authHeader } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); // Reset rate limiter state between tests so they don't interfere - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/backup.test.ts b/server/tests/integration/backup.test.ts index 183ac2c1..2cc79eec 100644 --- a/server/tests/integration/backup.test.ts +++ b/server/tests/integration/backup.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -39,7 +40,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Mock filesystem-dependent service functions to avoid real disk I/O in tests vi.mock('../../src/services/backupService', async () => { @@ -69,32 +72,34 @@ vi.mock('../../src/services/backupService', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createAdmin, createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import * as backupService from '../../src/services/backupService'; import fs from 'fs'; import path from 'path'; import os from 'os'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/bootstrap.test.ts b/server/tests/integration/bootstrap.test.ts new file mode 100644 index 00000000..53f69d05 --- /dev/null +++ b/server/tests/integration/bootstrap.test.ts @@ -0,0 +1,122 @@ +/** + * BOOTSTRAP / F6 — boots the unified production bootstrap (buildApp) and asserts + * the whole shell is intact on the single NestJS instance now that Express is gone: + * the global security pipeline (helmet/CSP), the /uploads platform routes, the + * migrated /api domains (with the JWT guard), the /api/health + /api/addons + * platform/inline endpoints, and (in production) HSTS. This is the test that proves + * server/src/bootstrap.ts + index.ts serve everything correctly without the legacy app. + */ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import type { INestApplication } from '@nestjs/common'; + +const { testDb, dbMock } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db + .prepare( + `SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`, + ) + .get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../src/db/database', () => dbMock); +vi.mock('../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', +})); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); + +import { createTables } from '../../src/db/schema'; +import { runMigrations } from '../../src/db/migrations'; +import { resetTestDb } from '../helpers/test-db'; +import { createUser } from '../helpers/factories'; +import { authCookie } from '../helpers/auth'; +import { buildApp } from '../../src/bootstrap'; + +describe('BOOTSTRAP (F6) — unified NestJS app serves the whole surface', () => { + let app: INestApplication; + let instance: import('express').Application; + + beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + resetTestDb(testDb); + app = await buildApp(); + instance = app.getHttpAdapter().getInstance(); + }); + + afterAll(async () => { + await app.close(); + testDb.close(); + }); + + it('BOOT-001 — GET /api/health returns 200 { status: ok } (platform transport on Nest)', async () => { + const res = await request(instance).get('/api/health'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + expect(res.headers['cache-control']).toContain('no-store'); + }); + + it('BOOT-002 — the global security pipeline (helmet) is applied', async () => { + const res = await request(instance).get('/api/health'); + // helmet defaults — proof applyGlobalMiddleware ran on the Nest instance. + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['content-security-policy']).toBeDefined(); + }); + + it('BOOT-003 — public /api/config is reachable without auth (migrated Nest domain)', async () => { + const res = await request(instance).get('/api/config'); + expect(res.status).toBe(200); + }); + + it('BOOT-004 — a protected /api domain rejects an anonymous request (JWT guard wired)', async () => { + const res = await request(instance).get('/api/trips'); + expect(res.status).toBe(401); + }); + + it('BOOT-005 — /uploads/files is blocked without auth (platform uploads on Nest)', async () => { + const res = await request(instance).get('/uploads/files/anything.bin'); + expect(res.status).toBe(401); + }); + + it('BOOT-006 — GET /api/addons works end-to-end (guard → Nest AddonsController)', async () => { + const anon = await request(instance).get('/api/addons'); + expect(anon.status).toBe(401); + + const { user } = createUser(testDb); + const res = await request(instance).get('/api/addons').set('Cookie', authCookie(user.id)); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.addons)).toBe(true); + }); + + it('BOOT-007 — HSTS is advertised when NODE_ENV=production', async () => { + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + let prodApp: INestApplication | undefined; + try { + prodApp = await buildApp(); + const res = await request(prodApp.getHttpAdapter().getInstance()).get('/api/health'); + expect(res.headers['strict-transport-security']).toContain('max-age='); + } finally { + if (prodApp) await prodApp.close(); + if (prev === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prev; + } + }); +}); diff --git a/server/tests/integration/budget.test.ts b/server/tests/integration/budget.test.ts index 7a1854df..6c1208b9 100644 --- a/server/tests/integration/budget.test.ts +++ b/server/tests/integration/budget.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/categories.test.ts b/server/tests/integration/categories.test.ts index 105244d2..c03dfe11 100644 --- a/server/tests/integration/categories.test.ts +++ b/server/tests/integration/categories.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/collab.test.ts b/server/tests/integration/collab.test.ts index f3cce3d8..75de00e9 100644 --- a/server/tests/integration/collab.test.ts +++ b/server/tests/integration/collab.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import path from 'path'; import fs from 'fs'; @@ -40,7 +41,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Partially mock collabService to make fetchLinkPreview controllable vi.mock('../../src/services/collabService', async (importOriginal) => { @@ -51,34 +54,36 @@ vi.mock('../../src/services/collabService', async (importOriginal) => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import * as collabService from '../../src/services/collabService'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); // Ensure uploads/files dir exists for collab file uploads const uploadsDir = path.join(__dirname, '../../uploads/files'); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/dayNotes.test.ts b/server/tests/integration/dayNotes.test.ts index c3f2845c..f9a02491 100644 --- a/server/tests/integration/dayNotes.test.ts +++ b/server/tests/integration/dayNotes.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/days.test.ts b/server/tests/integration/days.test.ts index 408b2d51..47fce35a 100644 --- a/server/tests/integration/days.test.ts +++ b/server/tests/integration/days.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ───────────────────────────────────────────────────────────────────────────── // In-memory DB — schema applied in beforeAll after mocks register @@ -38,20 +39,30 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); -beforeAll(() => { createTables(testDb); runMigrations(testDb); }); -beforeEach(() => { resetTestDb(testDb); loginAttempts.clear(); mfaAttempts.clear(); }); -afterAll(() => { testDb.close(); }); +let nestApp: INestApplication; +let app: Application; +beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); +beforeEach(() => { resetTestDb(testDb); resetRateLimits(nestApp); }); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ───────────────────────────────────────────────────────────────────────────── // List days (DAY-001, DAY-002) diff --git a/server/tests/integration/files.test.ts b/server/tests/integration/files.test.ts index 8d35b48c..4dd3c909 100644 --- a/server/tests/integration/files.test.ts +++ b/server/tests/integration/files.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import path from 'path'; import fs from 'fs'; @@ -42,26 +43,30 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createReservation, addTripMember } from '../helpers/factories'; import { authCookie, generateToken } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); // Ensure uploads/files dir exists const uploadsDir = path.join(__dirname, '../../uploads/files'); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); // Seed allowed_file_types to include common types (wildcard) testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); @@ -69,13 +74,13 @@ beforeAll(() => { beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); // Re-seed allowed_file_types after reset testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); fs.rmSync(uploadsDir, { recursive: true, force: true }); }); diff --git a/server/tests/integration/immich.test.ts b/server/tests/integration/immich.test.ts index 4ddd82f3..d6c549d2 100644 --- a/server/tests/integration/immich.test.ts +++ b/server/tests/integration/immich.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Mock SSRF guard: block loopback and private IPs, allow external hostnames without DNS. vi.mock('../../src/utils/ssrfGuard', async () => { @@ -64,28 +67,30 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/journey.test.ts b/server/tests/integration/journey.test.ts index 6748992a..3d27388f 100644 --- a/server/tests/integration/journey.test.ts +++ b/server/tests/integration/journey.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ───────────────────────────────────────────────────────────────────────────── // Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register @@ -43,6 +44,7 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), @@ -55,10 +57,10 @@ vi.mock('../../src/services/memories/immichService', () => ({ getImmichCredentials: vi.fn(() => null), })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, @@ -68,23 +70,30 @@ import { addJourneyContributor, } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { invalidatePermissionsCache } from '../../src/services/permissions'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); invalidatePermissionsCache(); // Enable the journey addon testDb.prepare( "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)" ).run(); }); -afterAll(() => { testDb.close(); }); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ───────────────────────────────────────────────────────────────────────────── // List journeys (JOURNEY-INT-001, 002) diff --git a/server/tests/integration/maps.test.ts b/server/tests/integration/maps.test.ts index bad3fa85..47ebaa62 100644 --- a/server/tests/integration/maps.test.ts +++ b/server/tests/integration/maps.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -38,7 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Default mock: resolveGoogleMapsUrl rejects with 400 (SSRF-like behaviour for // URLs that look internal); individual tests override with mockResolvedValueOnce. @@ -53,29 +56,31 @@ vi.mock('../../src/services/mapsService', () => ({ ), })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import * as mapsService from '../../src/services/mapsService'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index b8d0bc54..5a59071f 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -38,33 +39,37 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { generateToken } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { createMcpToken } from '../helpers/factories'; import { closeMcpSessions } from '../../src/mcp/index'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { closeMcpSessions(); + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/memories-immich.test.ts b/server/tests/integration/memories-immich.test.ts index 2b3cdefb..666e0723 100644 --- a/server/tests/integration/memories-immich.test.ts +++ b/server/tests/integration/memories-immich.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ── Hoisted DB mock ────────────────────────────────────────────────────────── @@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── SSRF guard mock — routes all Immich API calls to fake responses ─────────── vi.mock('../../src/utils/ssrfGuard', async () => { @@ -164,31 +166,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink, setImmichCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const IMMICH = '/api/integrations/memories/immich'; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => testDb.close()); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ── Connection status ───────────────────────────────────────────────────────── diff --git a/server/tests/integration/memories-synology.test.ts b/server/tests/integration/memories-synology.test.ts index 8f0acddf..c277a9ef 100644 --- a/server/tests/integration/memories-synology.test.ts +++ b/server/tests/integration/memories-synology.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ── Hoisted DB mock ────────────────────────────────────────────────────────── @@ -38,6 +39,7 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); @@ -190,31 +192,35 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, setSynologyCredentials } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { safeFetch } from '../../src/utils/ssrfGuard'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const SYNO = '/api/integrations/memories/synologyphotos'; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => testDb.close()); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ── Settings ────────────────────────────────────────────────────────────────── diff --git a/server/tests/integration/memories-unified.test.ts b/server/tests/integration/memories-unified.test.ts index 2d856201..4d3298a6 100644 --- a/server/tests/integration/memories-unified.test.ts +++ b/server/tests/integration/memories-unified.test.ts @@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ── Hoisted DB mock ────────────────────────────────────────────────────────── @@ -36,8 +37,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/utils/ssrfGuard', async () => { const actual = await vi.importActual('../../src/utils/ssrfGuard'); return { @@ -47,30 +49,34 @@ vi.mock('../../src/utils/ssrfGuard', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const BASE = '/api/integrations/memories/unified'; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => testDb.close()); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ── Helpers ────────────────────────────────────────────────────────────────── diff --git a/server/tests/integration/misc.test.ts b/server/tests/integration/misc.test.ts index fe0596a7..5b07982b 100644 --- a/server/tests/integration/misc.test.ts +++ b/server/tests/integration/misc.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); @@ -95,33 +100,36 @@ describe('Photo endpoint auth', () => { describe('Force HTTPS redirect', () => { it('MISC-004 — FORCE_HTTPS redirect sends 301 for HTTP requests on non-health paths', async () => { - // createApp() reads FORCE_HTTPS at call time, so we need a fresh app instance + // applyGlobalMiddleware reads FORCE_HTTPS when buildApp() composes the app, so + // we need a fresh Nest instance built with the flag set. process.env.FORCE_HTTPS = 'true'; - let httpsApp: Express; + let httpsApp: INestApplication | undefined; try { - httpsApp = createApp(); + httpsApp = await buildApp(); + const res = await request(httpsApp.getHttpAdapter().getInstance()) + .get('/api/addons') + .set('X-Forwarded-Proto', 'http'); + expect(res.status).toBe(301); } finally { + if (httpsApp) await httpsApp.close(); delete process.env.FORCE_HTTPS; } - const res = await request(httpsApp) - .get('/api/addons') - .set('X-Forwarded-Proto', 'http'); - expect(res.status).toBe(301); }); it('MISC-008 — FORCE_HTTPS does not redirect /api/health (probes must reach it over HTTP)', async () => { process.env.FORCE_HTTPS = 'true'; - let httpsApp: Express; + let httpsApp: INestApplication | undefined; try { - httpsApp = createApp(); + httpsApp = await buildApp(); + const res = await request(httpsApp.getHttpAdapter().getInstance()) + .get('/api/health') + .set('X-Forwarded-Proto', 'http'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); } finally { + if (httpsApp) await httpsApp.close(); delete process.env.FORCE_HTTPS; } - const res = await request(httpsApp) - .get('/api/health') - .set('X-Forwarded-Proto', 'http'); - expect(res.status).toBe(200); - expect(res.body.status).toBe('ok'); }); it('MISC-004 — no redirect when FORCE_HTTPS is not set', async () => { diff --git a/server/tests/integration/notifications.test.ts b/server/tests/integration/notifications.test.ts index 0eee9763..5d2df558 100644 --- a/server/tests/integration/notifications.test.ts +++ b/server/tests/integration/notifications.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -38,8 +39,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); -vi.mock('../../src/websocket', () => ({ broadcastToUser: vi.fn() })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/services/notifications', async (importOriginal) => { const actual = await importOriginal(); return { @@ -49,28 +51,30 @@ vi.mock('../../src/services/notifications', async (importOriginal) => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/oauth.test.ts b/server/tests/integration/oauth.test.ts index beeebd9b..4fc861fa 100644 --- a/server/tests/integration/oauth.test.ts +++ b/server/tests/integration/oauth.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import crypto from 'crypto'; const { testDb, dbMock } = vi.hoisted(() => { @@ -37,6 +38,7 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); const { isAddonEnabledMock } = vi.hoisted(() => { @@ -56,16 +58,16 @@ vi.mock('../../src/services/notifications', async (importOriginal) => { vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { createOAuthClient, createAuthCode, getUserByAccessToken } from '../../src/services/oauthService'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; // PKCE helpers function makePkce() { @@ -74,19 +76,33 @@ function makePkce() { return { verifier, challenge }; } -beforeAll(() => { +// A7: under the unified Nest app the adminService mock only reaches the directly +// imported isAddonEnabled (OauthService.mcpEnabled); oauthService.ts reads the +// addon state through its own import that the Nest module graph loads unmocked, +// so it falls back to the real DB row. Drive BOTH so the MCP-enabled state is +// consistent across mcpEnabled() AND validateAuthorizeRequest()/token/revoke. +function setMcpEnabled(enabled: boolean) { + isAddonEnabledMock.mockReturnValue(enabled); + testDb.prepare( + "INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)" + ).run(enabled ? 1 : 0); +} + +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); - isAddonEnabledMock.mockReturnValue(true); + resetRateLimits(nestApp); + setMcpEnabled(true); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); @@ -156,7 +172,7 @@ describe('POST /oauth/token — authorization_code grant', () => { }); it('OAUTH-003 — MCP addon disabled returns 404', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const res = await request(app) .post('/oauth/token') .send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' }); @@ -511,7 +527,7 @@ describe('POST /oauth/revoke', () => { describe('GET /api/oauth/authorize/validate', () => { it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const res = await request(app) .get('/api/oauth/authorize/validate') .query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' }); @@ -697,7 +713,7 @@ describe('POST /api/oauth/authorize', () => { }); it('OAUTH-029 — 403 when MCP disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -772,7 +788,7 @@ describe('POST /api/oauth/authorize', () => { describe('Client CRUD — /api/oauth/clients', () => { it('OAUTH-033 — GET returns 403 when addon disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -809,7 +825,7 @@ describe('Client CRUD — /api/oauth/clients', () => { }); it('OAUTH-036 — POST returns 403 when addon disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -859,7 +875,7 @@ describe('Client CRUD — /api/oauth/clients', () => { describe('Sessions — /api/oauth/sessions', () => { it('OAUTH-040 — GET returns 403 when addon disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -927,7 +943,7 @@ describe('Sessions — /api/oauth/sessions', () => { }); it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const { user } = createUser(testDb); const res = await request(app) @@ -952,13 +968,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => { describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => { it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const res = await request(app).get('/.well-known/oauth-authorization-server'); expect(res.status).toBe(404); }); it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => { - isAddonEnabledMock.mockReturnValue(false); + setMcpEnabled(false); const res = await request(app) .post('/oauth/revoke') .send({ token: 'x', client_id: 'y', client_secret: 'z' }); diff --git a/server/tests/integration/oidc.test.ts b/server/tests/integration/oidc.test.ts index a2c1a0d1..017145a8 100644 --- a/server/tests/integration/oidc.test.ts +++ b/server/tests/integration/oidc.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ── DB mock (inline vi.hoisted pattern) ────────────────────────────────────── @@ -34,7 +35,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // ── Mock only the HTTP-calling functions from oidcService ──────────────────── vi.mock('../../src/services/oidcService', async (importOriginal) => { @@ -52,12 +55,11 @@ vi.mock('../../src/services/oidcService', async (importOriginal) => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import * as oidcService from '../../src/services/oidcService'; const mockDiscover = vi.mocked(oidcService.discover); @@ -71,17 +73,19 @@ const MOCK_DISCOVERY_DOC = { userinfo_endpoint: 'https://oidc.example.com/userinfo', }; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); vi.clearAllMocks(); // Set OIDC environment variables for each test @@ -98,7 +102,8 @@ afterEach(() => { delete process.env.APP_URL; }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/packing.test.ts b/server/tests/integration/packing.test.ts index fb2f2247..c749319f 100644 --- a/server/tests/integration/packing.test.ts +++ b/server/tests/integration/packing.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createPackingItem, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/places.test.ts b/server/tests/integration/places.test.ts index 45aeac30..f2206a6c 100644 --- a/server/tests/integration/places.test.ts +++ b/server/tests/integration/places.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import path from 'path'; const { testDb, dbMock } = vi.hoisted(() => { @@ -41,7 +42,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); vi.mock('../../src/services/placeService', async (importOriginal) => { const actual = await importOriginal(); return { @@ -51,36 +54,38 @@ vi.mock('../../src/services/placeService', async (importOriginal) => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createTrip, createPlace, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import * as placeService from '../../src/services/placeService'; import { invalidatePermissionsCache } from '../../src/services/permissions'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const GPX_FIXTURE = path.join(__dirname, '../fixtures/test.gpx'); const KML_FIXTURE = path.join(__dirname, '../fixtures/test.kml'); const KML_NESTED_FIXTURE = path.join(__dirname, '../fixtures/test-nested.kml'); const KML_MALFORMED_FIXTURE = path.join(__dirname, '../fixtures/test-malformed.kml'); const KMZ_FIXTURE = path.join(__dirname, '../fixtures/test.kmz'); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); invalidatePermissionsCache(); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/profile.test.ts b/server/tests/integration/profile.test.ts index 5a6b5d24..d77ae780 100644 --- a/server/tests/integration/profile.test.ts +++ b/server/tests/integration/profile.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import path from 'path'; const { testDb, dbMock } = vi.hoisted(() => { @@ -36,32 +37,36 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createTrip } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg'); const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf'); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts index 9412871f..ae7196f7 100644 --- a/server/tests/integration/reservations.test.ts +++ b/server/tests/integration/reservations.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, createDay, createPlace, createReservation, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/security.test.ts b/server/tests/integration/security.test.ts index beda46a8..f5ad1088 100644 --- a/server/tests/integration/security.test.ts +++ b/server/tests/integration/security.test.ts @@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; import path from 'path'; import fs from 'fs'; @@ -42,35 +43,39 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip } from '../helpers/factories'; import { authCookie, authHeader, generateToken } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg'); const uploadsDir = path.join(__dirname, '../../uploads/files'); -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run(); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); fs.rmSync(uploadsDir, { recursive: true, force: true }); testDb.close(); }); diff --git a/server/tests/integration/settings.test.ts b/server/tests/integration/settings.test.ts index d1cecae7..d59c7827 100644 --- a/server/tests/integration/settings.test.ts +++ b/server/tests/integration/settings.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/share.test.ts b/server/tests/integration/share.test.ts index d7980efe..032a68db 100644 --- a/server/tests/integration/share.test.ts +++ b/server/tests/integration/share.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,30 +36,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember, createDay, createPlace, createDayAssignment, createDayNote } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/systemNotices.test.ts b/server/tests/integration/systemNotices.test.ts index 5bc88fae..853d5b3e 100644 --- a/server/tests/integration/systemNotices.test.ts +++ b/server/tests/integration/systemNotices.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ───────────────────────────────────────────────────────────────────────────── // Bare in-memory DB — schema applied in beforeAll after mocks register @@ -33,9 +34,11 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; import { resetTestDb } from '../helpers/test-db'; @@ -44,7 +47,8 @@ import { authCookie } from '../helpers/auth'; import { SYSTEM_NOTICES } from '../../src/systemNotices/registry'; import type { SystemNotice } from '../../src/systemNotices/types'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; // Test notice injected into the registry for notice-specific tests const TEST_NOTICE: SystemNotice = { @@ -59,16 +63,19 @@ const TEST_NOTICE: SystemNotice = { priority: 0, }; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/tags.test.ts b/server/tests/integration/tags.test.ts index ee588da2..b0dc7ad9 100644 --- a/server/tests/integration/tags.test.ts +++ b/server/tests/integration/tags.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -30,30 +31,34 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/todo.test.ts b/server/tests/integration/todo.test.ts index 83978723..7a3f25e3 100644 --- a/server/tests/integration/todo.test.ts +++ b/server/tests/integration/todo.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -30,32 +31,36 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip, addTripMember } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { invalidatePermissionsCache } from '../../src/services/permissions'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); invalidatePermissionsCache(); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); }); diff --git a/server/tests/integration/trips.test.ts b/server/tests/integration/trips.test.ts index 487407cb..acf1c6ab 100644 --- a/server/tests/integration/trips.test.ts +++ b/server/tests/integration/trips.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; // ───────────────────────────────────────────────────────────────────────────── // Step 1: Bare in-memory DB — schema applied in beforeAll after mocks register @@ -43,27 +44,36 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createAdmin, createTrip, addTripMember, createPlace, createReservation, createTag, createDayAccommodation, createBudgetItem, createPackingItem, createDayNote, createDayAssignment } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { invalidatePermissionsCache } from '../../src/services/permissions'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { createTables(testDb); runMigrations(testDb); }); +beforeAll(async () => { + createTables(testDb); + runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); +}); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); invalidatePermissionsCache(); }); -afterAll(() => { testDb.close(); }); +afterAll(async () => { + await nestApp.close(); + testDb.close(); +}); // ───────────────────────────────────────────────────────────────────────────── // Create trip (TRIP-001, TRIP-002, TRIP-003) diff --git a/server/tests/integration/vacay.test.ts b/server/tests/integration/vacay.test.ts index 1ba34e52..043db639 100644 --- a/server/tests/integration/vacay.test.ts +++ b/server/tests/integration/vacay.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; import type { Application } from 'express'; +import type { INestApplication } from '@nestjs/common'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -35,7 +36,9 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); +vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); // Prevent real HTTP calls (holiday API etc.) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ @@ -56,28 +59,30 @@ vi.mock('../../src/services/vacayService', async () => { }; }); -import { createApp } from '../../src/app'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; -const app: Application = createApp(); +let nestApp: INestApplication; +let app: Application; -beforeAll(() => { +beforeAll(async () => { createTables(testDb); runMigrations(testDb); + nestApp = await buildApp(); + app = nestApp.getHttpAdapter().getInstance(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); -afterAll(() => { +afterAll(async () => { + await nestApp.close(); testDb.close(); vi.unstubAllGlobals(); }); diff --git a/server/tests/parity/l10-todo.parity.test.ts b/server/tests/parity/l10-todo.parity.test.ts deleted file mode 100644 index 7524c792..00000000 --- a/server/tests/parity/l10-todo.parity.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * S3 parity — to-dos (trip-scoped). - * - * Same request at the legacy Express /api/trips/:tripId/todo route (mergeParams) - * and the migrated Nest controller, with todoService, the permission check, the - * WebSocket broadcast and auth all mocked identically. Asserts client-identical - * status + body, including trip 404, permission 403, and the create 201. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { svc } = vi.hoisted(() => ({ - svc: { - verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), - deleteItem: vi.fn(), reorderItems: vi.fn(), getCategoryAssignees: vi.fn(), updateCategoryAssignees: vi.fn(), - }, -})); -vi.mock('../../src/services/todoService', () => svc); - -import todoRoutes from '../../src/routes/todo'; -import { TodoModule } from '../../src/nest/todo/todo.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S3 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/todo', todoRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [TodoModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - svc.listItems.mockReturnValue([{ id: 1, name: 'Book hotel' }]); - svc.createItem.mockReturnValue({ id: 9, name: 'Book hotel' }); - svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null)); - svc.getCategoryAssignees.mockReturnValue([]); - }); - - beforeEach(() => { - svc.verifyTripAccess.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' })); - - it('GET / 404 when trip not accessible', () => { - svc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/todo' }); - }); - - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } })); - - it('POST / 403 without permission', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: { name: 'Book hotel' } }); - }); - - it('POST / 400 missing name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/todo', body: {} })); - - it('PUT /reorder', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/reorder', body: { orderedIds: [1, 2] } })); - - it('PUT /:id 404 when item missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/todo/77', body: { name: 'X' } })); - - it('GET /category-assignees', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/todo/category-assignees' })); -}); diff --git a/server/tests/parity/l11-budget.parity.test.ts b/server/tests/parity/l11-budget.parity.test.ts deleted file mode 100644 index 134c5dbd..00000000 --- a/server/tests/parity/l11-budget.parity.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * S4 parity — budget (trip-scoped). - * - * Same request at the legacy Express /api/trips/:tripId/budget route (mergeParams) - * and the migrated Nest controller, with budgetService, the permission check, the - * WebSocket broadcast, the DB and auth all mocked identically. Asserts - * client-identical status + body across the trip 404, permission 403, validation - * 400 and the create 201. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { svc } = vi.hoisted(() => ({ - svc: { - verifyTripAccess: vi.fn(), listBudgetItems: vi.fn(), createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), - deleteBudgetItem: vi.fn(), updateMembers: vi.fn(), toggleMemberPaid: vi.fn(), getPerPersonSummary: vi.fn(), - calculateSettlement: vi.fn(), reorderBudgetItems: vi.fn(), reorderBudgetCategories: vi.fn(), - }, -})); -vi.mock('../../src/services/budgetService', () => svc); - -import budgetRoutes from '../../src/routes/budget'; -import { BudgetModule } from '../../src/nest/budget/budget.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S4 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/budget', budgetRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [BudgetModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - svc.listBudgetItems.mockReturnValue([{ id: 1, name: 'Hotel' }]); - svc.createBudgetItem.mockReturnValue({ id: 9, name: 'Hotel' }); - svc.updateBudgetItem.mockImplementation((id: string) => (id === '9' ? { id: 9, reservation_id: null, total_price: 100 } : null)); - svc.updateMembers.mockReturnValue({ members: [{ user_id: 2 }], item: { persons: 1 } }); - svc.toggleMemberPaid.mockReturnValue({ user_id: 2, paid: 1 }); - svc.getPerPersonSummary.mockReturnValue([{ userId: 1 }]); - svc.calculateSettlement.mockReturnValue({ transfers: [] }); - }); - - beforeEach(() => { - svc.verifyTripAccess.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' })); - it('GET / 404 trip', () => { - svc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/budget' }); - }); - it('GET /summary/per-person', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/summary/per-person' })); - it('GET /settlement', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/budget/settlement' })); - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } })); - it('POST / 403 no permission', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: { name: 'Hotel' } }); - }); - it('POST / 400 missing name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/budget', body: {} })); - it('PUT /reorder/items', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/items', body: { orderedIds: [1, 2] } })); - it('PUT /reorder/categories', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/reorder/categories', body: { orderedCategories: ['a'] } })); - it('PUT /:id 404 when item missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/77', body: { name: 'X' } })); - it('PUT /:id/members 400 not array', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/budget/9/members', body: { user_ids: 'no' } })); - it('DELETE /:id 404 when missing', () => { - svc.deleteBudgetItem.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/budget/77' }); - }); -}); diff --git a/server/tests/parity/l12-reservations.parity.test.ts b/server/tests/parity/l12-reservations.parity.test.ts deleted file mode 100644 index 84036140..00000000 --- a/server/tests/parity/l12-reservations.parity.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * S5 parity — reservations + accommodations (trip-scoped). - * - * Fires the same request at the legacy Express routes (reservations route + - * accommodations sub-router from routes/days.ts, both mounted with mergeParams) - * and the migrated Nest controllers, with the reservation/day/budget services, - * the permission check, canAccessTrip, the WebSocket broadcast and auth all - * mocked identically. Asserts client-identical status + body across the trip - * 404, permission 403, validation 400/404 and the create 201. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - canAccessTrip, - isOwner: vi.fn(() => true), - getPlaceWithTags: vi.fn(), - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { resv, budget, day } = vi.hoisted(() => ({ - resv: { - verifyTripAccess: vi.fn(), listReservations: vi.fn(), createReservation: vi.fn(), updatePositions: vi.fn(), - getReservation: vi.fn(), updateReservation: vi.fn(), deleteReservation: vi.fn(), - }, - budget: { createBudgetItem: vi.fn(), updateBudgetItem: vi.fn(), deleteBudgetItem: vi.fn(), linkBudgetItemToReservation: vi.fn() }, - day: { - listAccommodations: vi.fn(), validateAccommodationRefs: vi.fn(), createAccommodation: vi.fn(), - getAccommodation: vi.fn(), updateAccommodation: vi.fn(), deleteAccommodation: vi.fn(), - }, -})); -vi.mock('../../src/services/reservationService', () => resv); -vi.mock('../../src/services/budgetService', () => budget); -vi.mock('../../src/services/dayService', () => day); - -import reservationsRoutes from '../../src/routes/reservations'; -import { accommodationsRouter } from '../../src/routes/days'; -import { ReservationsModule } from '../../src/nest/reservations/reservations.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S5 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/reservations', reservationsRoutes); - app.use('/api/trips/:tripId/accommodations', accommodationsRouter); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [ReservationsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - resv.listReservations.mockReturnValue([{ id: 1, title: 'Hotel' }]); - resv.createReservation.mockReturnValue({ reservation: { id: 9, title: 'Hotel' }, accommodationCreated: false }); - resv.getReservation.mockImplementation((id: string) => (id === '9' ? { title: 'Hotel', type: 'lodging' } : undefined)); - resv.updateReservation.mockReturnValue({ reservation: { id: 9 }, accommodationChanged: false }); - resv.deleteReservation.mockReturnValue({ deleted: { id: 9, title: 'Hotel', type: 'lodging', accommodation_id: null }, accommodationDeleted: false, deletedBudgetItemId: null }); - day.listAccommodations.mockReturnValue([{ id: 1 }]); - day.validateAccommodationRefs.mockReturnValue([]); - day.createAccommodation.mockReturnValue({ id: 9 }); - day.getAccommodation.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined)); - day.updateAccommodation.mockReturnValue({ id: 9 }); - day.deleteAccommodation.mockReturnValue({ linkedReservationId: null, deletedBudgetItemId: null }); - }); - - beforeEach(() => { - resv.verifyTripAccess.mockReturnValue(trip); - canAccessTrip.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - // Reservations - it('GET /reservations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' })); - it('GET /reservations 404 trip', () => { - resv.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/reservations' }); - }); - it('POST /reservations create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } })); - it('POST /reservations 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: { title: 'Hotel' } }); - }); - it('POST /reservations 400 missing title', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/reservations', body: {} })); - it('PUT /reservations/positions 400 not array', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/positions', body: { positions: 'no' } })); - it('PUT /reservations/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/reservations/77', body: { title: 'X' } })); - it('DELETE /reservations/:id success', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/reservations/9' })); - - // Accommodations - it('GET /accommodations', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' })); - it('GET /accommodations 404 trip', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/accommodations' }); - }); - it('POST /accommodations create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } })); - it('POST /accommodations 400 missing refs', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2 } })); - it('POST /accommodations 404 bad ref', () => { - day.validateAccommodationRefs.mockReturnValue([{ field: 'place_id', message: 'Place not found' }]); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/accommodations', body: { place_id: 2, start_day_id: 10, end_day_id: 11 } }); - }); - it('PUT /accommodations/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/accommodations/77', body: {} })); - it('DELETE /accommodations/:id success', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/accommodations/9' })); -}); diff --git a/server/tests/parity/l13-days.parity.test.ts b/server/tests/parity/l13-days.parity.test.ts deleted file mode 100644 index 695d7f7c..00000000 --- a/server/tests/parity/l13-days.parity.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * S6 parity — days + day notes (trip-scoped). - * - * Same request at the legacy Express days route + the day-notes route (both - * mergeParams) and the migrated Nest controllers, with dayService / - * dayNoteService, the permission check, canAccessTrip, the WebSocket broadcast - * and auth all mocked identically. Covers trip 404, permission 403, the bespoke - * 404s, the create 201, and the string-length-before-access ordering. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { day, note } = vi.hoisted(() => ({ - day: { listDays: vi.fn(), createDay: vi.fn(), getDay: vi.fn(), updateDay: vi.fn(), deleteDay: vi.fn() }, - note: { - verifyTripAccess: vi.fn(), listNotes: vi.fn(), dayExists: vi.fn(), createNote: vi.fn(), - getNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(), - }, -})); -vi.mock('../../src/services/dayService', () => day); -vi.mock('../../src/services/dayNoteService', () => note); - -import daysRoutes from '../../src/routes/days'; -import dayNotesRoutes from '../../src/routes/dayNotes'; -import { DaysModule } from '../../src/nest/days/days.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S6 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/days/:dayId/notes', dayNotesRoutes); - app.use('/api/trips/:tripId/days', daysRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [DaysModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - day.listDays.mockReturnValue({ days: [{ id: 1 }] }); - day.createDay.mockReturnValue({ id: 9 }); - day.getDay.mockImplementation((id: string) => (id === '9' ? { id: 9 } : undefined)); - day.updateDay.mockReturnValue({ id: 9, title: 'T' }); - note.listNotes.mockReturnValue([{ id: 1 }]); - note.dayExists.mockReturnValue(true); - note.createNote.mockReturnValue({ id: 7 }); - note.getNote.mockImplementation((id: string) => (id === '7' ? { id: 7 } : undefined)); - note.updateNote.mockReturnValue({ id: 7 }); - }); - - beforeEach(() => { - canAccessTrip.mockReturnValue(trip); - note.verifyTripAccess.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - // Days - it('GET /days', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days' })); - it('GET /days 404 trip', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/days' }); - }); - it('POST /days create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: { date: '2026-07-01' } })); - it('POST /days 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days', body: {} }); - }); - it('PUT /days/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/77', body: { title: 'X' } })); - it('DELETE /days/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/77' })); - - // Day notes - it('GET notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/notes' })); - it('POST notes create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'Lunch', time: '12:00' } })); - it('POST notes 400 empty text', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: ' ' } })); - it('POST notes 400 over-long text (before access)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'x'.repeat(501) } })); - it('POST notes 404 day not found', () => { - note.dayExists.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/notes', body: { text: 'ok' } }); - }); - it('PUT notes/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/notes/99', body: { text: 'x' } })); - it('DELETE notes/:id 404 missing', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/notes/99' })); -}); diff --git a/server/tests/parity/l14-assignments.parity.test.ts b/server/tests/parity/l14-assignments.parity.test.ts deleted file mode 100644 index 333a6faf..00000000 --- a/server/tests/parity/l14-assignments.parity.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * S7 parity — assignments (place↔day itinerary). - * - * Same request at the legacy Express assignments route (mounted on /api, with - * full /trips/... paths) and the migrated Nest controllers, with - * assignmentService, journeyService.onPlaceCreated, the permission check, - * canAccessTrip, the WebSocket broadcast and auth all mocked identically. Covers - * trip 404, permission 403, the bespoke 404s, the create 201 and validation 400. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); -vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { asg } = vi.hoisted(() => ({ - asg: { - getAssignmentWithPlace: vi.fn(), listDayAssignments: vi.fn(), dayExists: vi.fn(), placeExists: vi.fn(), - createAssignment: vi.fn(), assignmentExistsInDay: vi.fn(), deleteAssignment: vi.fn(), reorderAssignments: vi.fn(), - getAssignmentForTrip: vi.fn(), moveAssignment: vi.fn(), getParticipants: vi.fn(), updateTime: vi.fn(), setParticipants: vi.fn(), - }, -})); -vi.mock('../../src/services/assignmentService', () => asg); - -import assignmentsRoutes from '../../src/routes/assignments'; -import { AssignmentsModule } from '../../src/nest/assignments/assignments.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S7 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api', assignmentsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [AssignmentsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - asg.listDayAssignments.mockReturnValue([{ id: 1 }]); - asg.createAssignment.mockReturnValue({ id: 9 }); - asg.assignmentExistsInDay.mockReturnValue(true); - asg.getAssignmentForTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, day_id: 3 } : undefined)); - asg.moveAssignment.mockReturnValue({ assignment: { id: 9 } }); - asg.getParticipants.mockReturnValue([{ user_id: 2 }]); - asg.updateTime.mockReturnValue({ id: 9 }); - asg.setParticipants.mockReturnValue([{ user_id: 2 }]); - }); - - beforeEach(() => { - canAccessTrip.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - asg.dayExists.mockReturnValue(true); - asg.placeExists.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET day-assignments', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' })); - it('GET day-assignments 404 day', () => { - asg.dayExists.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/days/3/assignments' }); - }); - it('POST create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } })); - it('POST 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 2 } }); - }); - it('POST 404 place', () => { - asg.placeExists.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/days/3/assignments', body: { place_id: 99 } }); - }); - it('PUT reorder', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/days/3/assignments/reorder', body: { orderedIds: [1, 2] } })); - it('DELETE /:id 404 not in day', () => { - asg.assignmentExistsInDay.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/days/3/assignments/77' }); - }); - it('PUT move 404 assignment', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/77/move', body: { new_day_id: 4 } })); - it('PUT move success', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/move', body: { new_day_id: 4, order_index: 0 } })); - it('GET participants', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/assignments/9/participants' })); - it('PUT time success', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/time', body: { place_time: '10:00' } })); - it('PUT participants 400 not array', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/assignments/9/participants', body: { user_ids: 'no' } })); -}); diff --git a/server/tests/parity/l15-places.parity.test.ts b/server/tests/parity/l15-places.parity.test.ts deleted file mode 100644 index 2d0ad23e..00000000 --- a/server/tests/parity/l15-places.parity.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * S8 parity — places (trip-scoped). - * - * Same request at the legacy Express /api/trips/:tripId/places route (mergeParams) - * and the migrated Nest controller, with placeService, journeyService, the - * permission check, canAccessTrip, the WebSocket broadcast and auth mocked - * identically. Covers the JSON endpoints (the multer file imports are covered by - * the controller unit test): trip 404, length 400, permission 403, name 400, - * list-import error mapping, bulk-delete validation, and the create 201. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); -vi.mock('../../src/services/journeyService', () => ({ onPlaceCreated: vi.fn(), onPlaceUpdated: vi.fn(), onPlaceDeleted: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { pl } = vi.hoisted(() => ({ - pl: { - listPlaces: vi.fn(), createPlace: vi.fn(), getPlace: vi.fn(), updatePlace: vi.fn(), deletePlace: vi.fn(), - deletePlacesMany: vi.fn(), importGpx: vi.fn(), importMapFile: vi.fn(), importGoogleList: vi.fn(), - importNaverList: vi.fn(), searchPlaceImage: vi.fn(), - }, -})); -vi.mock('../../src/services/placeService', () => pl); - -import placesRoutes from '../../src/routes/places'; -import { PlacesModule } from '../../src/nest/places/places.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S8 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/places', placesRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [PlacesModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - pl.listPlaces.mockReturnValue([{ id: 1, name: 'Spot' }]); - pl.createPlace.mockReturnValue({ id: 9, name: 'Spot' }); - pl.getPlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : undefined)); - pl.updatePlace.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null)); - pl.deletePlace.mockImplementation((_t: string, id: string) => id === '9'); - pl.deletePlacesMany.mockReturnValue([1, 2]); - pl.importGoogleList.mockResolvedValue({ places: [{ id: 1 }], listName: 'L', skipped: 0 }); - pl.importNaverList.mockResolvedValue({ error: 'List is empty', status: 400 }); - pl.searchPlaceImage.mockResolvedValue({ photos: [{ url: 'x' }] }); - }); - - beforeEach(() => { - canAccessTrip.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/places', query: { search: 'sp' } })); - it('GET / 404 trip', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/places' }); - }); - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } })); - it('POST / 400 over-long name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'x'.repeat(201) } })); - it('POST / 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: { name: 'Spot' } }); - }); - it('POST / 400 missing name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places', body: {} })); - it('POST /import/google-list success (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: { url: 'http://x' } })); - it('POST /import/google-list 400 missing url', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/google-list', body: {} })); - it('POST /import/naver-list service error', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/import/naver-list', body: { url: 'http://x' } })); - it('POST /bulk-delete 400 not numbers', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: ['a'] } })); - it('POST /bulk-delete empty', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [] } })); - it('POST /bulk-delete success', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/places/bulk-delete', body: { ids: [1, 2] } })); - it('GET /:id 404', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/places/77' })); - it('GET /:id found', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9' })); - it('GET /:id/image', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/places/9/image' })); - it('PUT /:id 404', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/places/77', body: { name: 'X' } })); - it('DELETE /:id success', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/places/9' })); -}); diff --git a/server/tests/parity/l16-trips.parity.test.ts b/server/tests/parity/l16-trips.parity.test.ts deleted file mode 100644 index a894b057..00000000 --- a/server/tests/parity/l16-trips.parity.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * C1 parity — trips aggregate root. - * - * Same request at the legacy Express /api/trips route and the migrated Nest - * controller, with tripService, the bundle list-services, auditLog, demo, - * the permission check, the WebSocket broadcast and auth mocked identically. - * Covers the own-routes (list/create/get/update/delete/members/copy/bundle); - * the exact-prefix routing (not capturing collab/files) is unit-tested in the - * strangler spec. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) }, - canAccessTrip, isOwner: vi.fn(() => true), getPlaceWithTags: vi.fn(), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4'), logInfo: vi.fn() })); -vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { tripSvc } = vi.hoisted(() => ({ - tripSvc: { - listTrips: vi.fn(), createTrip: vi.fn(), getTrip: vi.fn(), updateTrip: vi.fn(), deleteTrip: vi.fn(), - getTripRaw: vi.fn(), getTripOwner: vi.fn(), deleteOldCover: vi.fn(), updateCoverImage: vi.fn(), - listMembers: vi.fn(), addMember: vi.fn(), removeMember: vi.fn(), exportICS: vi.fn(), copyTripById: vi.fn(), - verifyTripAccess: vi.fn(), NotFoundError: class NotFoundError extends Error {}, ValidationError: class ValidationError extends Error {}, - TRIP_SELECT: 'SELECT * FROM trips t', - }, -})); -vi.mock('../../src/services/tripService', () => tripSvc); -// Bundle list-services — return empty collections. -vi.mock('../../src/services/dayService', () => ({ listDays: () => ({ days: [] }), listAccommodations: () => [] })); -vi.mock('../../src/services/placeService', () => ({ listPlaces: () => [] })); -vi.mock('../../src/services/packingService', () => ({ listItems: () => [] })); -vi.mock('../../src/services/todoService', () => ({ listItems: () => [] })); -vi.mock('../../src/services/budgetService', () => ({ listBudgetItems: () => [] })); -vi.mock('../../src/services/reservationService', () => ({ listReservations: () => [] })); -vi.mock('../../src/services/fileService', () => ({ listFiles: () => [] })); - -import tripsRoutes from '../../src/routes/trips'; -import { TripsModule } from '../../src/nest/trips/trips.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C1 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips', tripsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [TripsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - tripSvc.listTrips.mockReturnValue([{ id: 1, title: 'T' }]); - tripSvc.createTrip.mockReturnValue({ trip: { id: 9 }, tripId: 9, reminderDays: 0 }); - tripSvc.getTrip.mockImplementation((id: string) => (id === '9' ? { id: 9, user_id: 1 } : undefined)); - tripSvc.updateTrip.mockReturnValue({ updatedTrip: { id: 9 }, changes: {}, newTitle: 'T', newReminder: 0, oldReminder: 0 }); - tripSvc.getTripOwner.mockReturnValue({ user_id: 1 }); - tripSvc.deleteTrip.mockReturnValue({ tripId: 9, title: 'T', isAdminDelete: false }); - tripSvc.listMembers.mockReturnValue({ owner: { id: 1 }, members: [] }); - tripSvc.copyTripById.mockReturnValue(42); - }); - - beforeEach(() => { - canAccessTrip.mockReturnValue({ user_id: 1 }); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/trips' })); - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } })); - it('POST / 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T' } }); - }); - it('POST / 400 missing title', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: {} })); - it('POST / 400 end before start', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips', body: { title: 'T', start_date: '2026-07-10', end_date: '2026-07-01' } })); - it('GET /:id found', () => expectParity(expressServer, nestServer, { path: '/api/trips/9' })); - it('GET /:id 404', () => { - tripSvc.getTrip.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/77' }); - }); - it('PUT /:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } })); - it('PUT /:id 404 no access', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/9', body: { title: 'b' } }); - }); - it('POST /:id/copy (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/copy', body: { title: 'Copy' } })); - it('DELETE /:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/9' })); - it('GET /:id/members', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/members' })); - it('POST /:id/members (201)', () => { - tripSvc.addMember.mockReturnValue({ member: { id: 2, email: 'b@x.y' }, targetUserId: 2, tripTitle: 'T' }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/9/members', body: { identifier: 'b@x.y' } }); - }); - it('GET /:id/bundle', () => expectParity(expressServer, nestServer, { path: '/api/trips/9/bundle' })); -}); diff --git a/server/tests/parity/l17-collab.parity.test.ts b/server/tests/parity/l17-collab.parity.test.ts deleted file mode 100644 index fa224185..00000000 --- a/server/tests/parity/l17-collab.parity.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -/** - * C2 parity — collab (shared notes, polls, chat + reactions, link previews). - * - * Same request at the legacy Express /api/trips/:tripId/collab route and the - * migrated Nest controller, with collabService, permissions, the WebSocket - * broadcast, the notification fire-and-forget, the db and auth mocked - * identically. File uploads are exercised by the e2e/unit specs (multer differs - * per framework); this pins routing, status codes, the error envelopes and the - * poll/message error-string mapping. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => ({ title: 'T' }), all: () => [], run: () => undefined }) }, - canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); -vi.mock('../../src/services/notificationService', () => ({ send: vi.fn().mockResolvedValue(undefined) })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { collabSvc } = vi.hoisted(() => ({ - collabSvc: { - verifyTripAccess: vi.fn(), listNotes: vi.fn(), createNote: vi.fn(), updateNote: vi.fn(), deleteNote: vi.fn(), - addNoteFile: vi.fn(), getFormattedNoteById: vi.fn(), deleteNoteFile: vi.fn(), - listPolls: vi.fn(), createPoll: vi.fn(), votePoll: vi.fn(), closePoll: vi.fn(), deletePoll: vi.fn(), - listMessages: vi.fn(), createMessage: vi.fn(), deleteMessage: vi.fn(), addOrRemoveReaction: vi.fn(), fetchLinkPreview: vi.fn(), - }, -})); -vi.mock('../../src/services/collabService', () => collabSvc); - -import collabRoutes from '../../src/routes/collab'; -import { CollabModule } from '../../src/nest/collab/collab.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C2 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/collab', collabRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [CollabModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - collabSvc.listNotes.mockReturnValue([{ id: 1, title: 'N' }]); - collabSvc.createNote.mockReturnValue({ id: 9, title: 'N' }); - collabSvc.updateNote.mockReturnValue({ id: 9, title: 'N2' }); - collabSvc.deleteNote.mockReturnValue(true); - collabSvc.listPolls.mockReturnValue([{ id: 1 }]); - collabSvc.createPoll.mockReturnValue({ id: 7 }); - collabSvc.votePoll.mockReturnValue({ poll: { id: 7 } }); - collabSvc.closePoll.mockReturnValue({ id: 7, closed: 1 }); - collabSvc.deletePoll.mockReturnValue(true); - collabSvc.listMessages.mockReturnValue([{ id: 1, text: 'hi' }]); - collabSvc.createMessage.mockReturnValue({ message: { id: 3, text: 'hi' } }); - collabSvc.deleteMessage.mockReturnValue({ username: 'u' }); - collabSvc.addOrRemoveReaction.mockReturnValue({ found: true, reactions: [{ emoji: '👍', count: 1 }] }); - collabSvc.fetchLinkPreview.mockResolvedValue({ title: 'T', description: null, image: null, url: 'http://x' }); - }); - - beforeEach(() => { - collabSvc.verifyTripAccess.mockReturnValue({ user_id: 1 }); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - // Notes - it('GET /notes', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' })); - it('GET /notes 404 no access', () => { - collabSvc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/notes' }); - }); - it('POST /notes (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } })); - it('POST /notes 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: { title: 'N' } }); - }); - it('POST /notes 400 missing title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/notes', body: {} })); - it('PUT /notes/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'N2' } })); - it('PUT /notes/:id 404', () => { - collabSvc.updateNote.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/notes/9', body: { title: 'x' } }); - }); - it('DELETE /notes/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' })); - it('DELETE /notes/:id 404', () => { - collabSvc.deleteNote.mockReturnValueOnce(false).mockReturnValueOnce(false); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/notes/9' }); - }); - - // Polls - it('GET /polls', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/polls' })); - it('POST /polls (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a', 'b'] } })); - it('POST /polls 400 missing question', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { options: ['a', 'b'] } })); - it('POST /polls 400 too few options', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls', body: { question: 'q', options: ['a'] } })); - it('POST /polls/:id/vote (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } })); - it('POST /polls/:id/vote 404', () => { - collabSvc.votePoll.mockReturnValueOnce({ error: 'not_found' }).mockReturnValueOnce({ error: 'not_found' }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } }); - }); - it('POST /polls/:id/vote 400 closed', () => { - collabSvc.votePoll.mockReturnValueOnce({ error: 'closed' }).mockReturnValueOnce({ error: 'closed' }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/polls/7/vote', body: { option_index: 0 } }); - }); - it('PUT /polls/:id/close', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/collab/polls/7/close' })); - it('DELETE /polls/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/polls/7' })); - - // Messages - it('GET /messages', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/messages' })); - it('POST /messages (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi' } })); - it('POST /messages 400 too long', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'x'.repeat(5001) } })); - it('POST /messages 400 empty', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: ' ' } })); - it('POST /messages 400 reply_not_found', () => { - collabSvc.createMessage.mockReturnValueOnce({ error: 'reply_not_found' }).mockReturnValueOnce({ error: 'reply_not_found' }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages', body: { text: 'hi', reply_to: 99 } }); - }); - it('POST /messages/:id/react (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } })); - it('POST /messages/:id/react 404', () => { - collabSvc.addOrRemoveReaction.mockReturnValueOnce({ found: false, reactions: [] }).mockReturnValueOnce({ found: false, reactions: [] }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/collab/messages/3/react', body: { emoji: '👍' } }); - }); - it('DELETE /messages/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' })); - it('DELETE /messages/:id 403 not owner', () => { - collabSvc.deleteMessage.mockReturnValueOnce({ error: 'not_owner' }).mockReturnValueOnce({ error: 'not_owner' }); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/collab/messages/3' }); - }); - - // Link preview - it('GET /link-preview', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview', query: { url: 'http://x' } })); - it('GET /link-preview 400 missing url', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/collab/link-preview' })); -}); diff --git a/server/tests/parity/l18-files.parity.test.ts b/server/tests/parity/l18-files.parity.test.ts deleted file mode 100644 index be70acc1..00000000 --- a/server/tests/parity/l18-files.parity.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * C3 parity — files (trip file manager) + photos (global photo access). - * - * Same request at the legacy Express routes and the migrated Nest controllers, - * with the file/photo services, permissions, the WebSocket broadcast, demo and - * auth mocked identically. Multipart upload + the sendFile/stream success bodies - * differ per framework (multer vs FileInterceptor, res.sendFile), so this pins - * routing, status codes and the JSON error envelopes — including the unguarded - * download's token-auth errors and the photo id/access guards. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => ({ id: 42 }), all: () => [], run: () => undefined }) }, - canAccessTrip: vi.fn(() => ({ user_id: 1 })), closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); -vi.mock('../../src/middleware/tripAccess', () => ({ - requireTripAccess: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { trip: unknown }).trip = { user_id: 1 }; - next(); - }, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { fileSvc } = vi.hoisted(() => ({ - fileSvc: { - // Constants the route + controller read at import time. - MAX_FILE_SIZE: 50 * 1024 * 1024, - BLOCKED_EXTENSIONS: ['.exe', '.svg'], - filesDir: '/tmp/files', - getAllowedExtensions: () => '*', - verifyTripAccess: vi.fn(), formatFile: vi.fn(), resolveFilePath: vi.fn(), authenticateDownload: vi.fn(), - listFiles: vi.fn(), getFileById: vi.fn(), getFileByIdFull: vi.fn(), getDeletedFile: vi.fn(), - createFile: vi.fn(), updateFile: vi.fn(), toggleStarred: vi.fn(), softDeleteFile: vi.fn(), - restoreFile: vi.fn(), permanentDeleteFile: vi.fn(), emptyTrash: vi.fn(), createFileLink: vi.fn(), - deleteFileLink: vi.fn(), getFileLinks: vi.fn(), - }, -})); -vi.mock('../../src/services/fileService', () => fileSvc); - -vi.mock('../../src/services/demo', () => ({ isDemoEmail: vi.fn(() => false) })); - -const { photoSvc, helperSvc } = vi.hoisted(() => ({ - photoSvc: { streamPhoto: vi.fn(), getPhotoInfo: vi.fn(), resolveTrekPhoto: vi.fn() }, - helperSvc: { canAccessTrekPhoto: vi.fn() }, -})); -vi.mock('../../src/services/memories/photoResolverService', () => photoSvc); -vi.mock('../../src/services/memories/helpersService', () => helperSvc); - -import filesRoutes from '../../src/routes/files'; -import photosRoutes from '../../src/routes/photos'; -import { FilesModule } from '../../src/nest/files/files.module'; -import { PhotosModule } from '../../src/nest/photos/photos.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C3 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/files', filesRoutes); - app.use('/api/photos', photosRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [FilesModule, PhotosModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - fileSvc.listFiles.mockReturnValue([{ id: 1, original_name: 'a.pdf' }]); - fileSvc.getFileById.mockReturnValue({ id: 9, starred: 0, description: 'x' }); - fileSvc.getDeletedFile.mockReturnValue({ id: 9 }); - fileSvc.updateFile.mockReturnValue({ id: 9, description: 'new' }); - fileSvc.toggleStarred.mockReturnValue({ id: 9, starred: 1 }); - fileSvc.restoreFile.mockReturnValue({ id: 9 }); - fileSvc.permanentDeleteFile.mockResolvedValue(undefined); - fileSvc.emptyTrash.mockResolvedValue(2); - fileSvc.createFileLink.mockReturnValue([{ id: 1 }]); - fileSvc.getFileLinks.mockReturnValue([{ id: 1 }]); - fileSvc.authenticateDownload.mockReturnValue({ error: 'Authentication required', status: 401 }); - }); - - beforeEach(() => { - fileSvc.verifyTripAccess.mockReturnValue({ user_id: 1 }); - checkPermission.mockReturnValue(true); - helperSvc.canAccessTrekPhoto.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - // Files — JSON endpoints - it('GET /files', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files' })); - it('GET /files?trash=true', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files', query: { trash: 'true' } })); - it('GET /files 404 no access', () => { - fileSvc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/files' }); - }); - it('PUT /files/:id', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'new' } })); - it('PUT /files/:id 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: { description: 'x' } }); - }); - it('PUT /files/:id 404', () => { - fileSvc.getFileById.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined); - return expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/files/9', body: {} }); - }); - it('PATCH /files/:id/star', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/trips/5/files/9/star' })); - it('DELETE /files/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9' })); - it('POST /files/:id/restore (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' })); - it('POST /files/:id/restore 404 not in trash', () => { - fileSvc.getDeletedFile.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/restore' }); - }); - it('DELETE /files/:id/permanent', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/permanent' })); - it('DELETE /files/trash/empty', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/trash/empty' })); - it('POST /files/:id/link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/files/9/link', body: { reservation_id: 2 } })); - it('DELETE /files/:id/link/:linkId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/files/9/link/3' })); - it('GET /files/:id/links', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/links' })); - - // Files — download (unguarded), error paths only (sendFile body differs) - it('GET /files/:id/download 401 (token)', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' })); - it('GET /files/:id/download 404 no access', () => { - fileSvc.authenticateDownload.mockReturnValue({ userId: 1 }); - fileSvc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/files/9/download' }); - }); - - // Photos — guard paths only (stream/info success writes binary/json via res) - it('GET /photos/:id/thumbnail 400 invalid id', () => expectParity(expressServer, nestServer, { path: '/api/photos/abc/thumbnail' })); - it('GET /photos/:id/original 403 no access', () => { - helperSvc.canAccessTrekPhoto.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/photos/5/original' }); - }); - it('GET /photos/:id/info 403 no access', () => { - helperSvc.canAccessTrekPhoto.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/photos/5/info' }); - }); -}); diff --git a/server/tests/parity/l19-journey.parity.test.ts b/server/tests/parity/l19-journey.parity.test.ts deleted file mode 100644 index a5cc9a02..00000000 --- a/server/tests/parity/l19-journey.parity.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -/** - * C4 parity — journey (authenticated) + public journey share. - * - * Same request at the legacy Express routes and the migrated Nest controllers, - * with journeyService, journeyShareService, the addon gate, db and auth mocked - * identically. Multipart photo uploads + the stream/sendFile success bodies - * differ per framework, so this pins routing, the addon-gate 404, status codes - * (create 201 vs cover/trips/share 200 vs unlink 204) and the JSON envelopes. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => ({ immich_auto_upload: 0 }), all: () => [], run: () => undefined }) }, - closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); -vi.mock('../../src/services/fileService', () => ({ getAllowedExtensions: () => '*' })); -vi.mock('../../src/services/memories/immichService', () => ({ uploadToImmich: vi.fn(), streamImmichAsset: vi.fn() })); -vi.mock('../../src/services/memories/photoResolverService', () => ({ streamPhoto: vi.fn() })); - -const { jsvc } = vi.hoisted(() => ({ - jsvc: { - canAccessJourney: vi.fn(), isOwner: vi.fn(), canEdit: vi.fn(), - listJourneys: vi.fn(), createJourney: vi.fn(), getJourneyFull: vi.fn(), updateJourney: vi.fn(), - updateJourneyPreferences: vi.fn(), deleteJourney: vi.fn(), addTripToJourney: vi.fn(), removeTripFromJourney: vi.fn(), - listEntries: vi.fn(), createEntry: vi.fn(), updateEntry: vi.fn(), reorderEntries: vi.fn(), deleteEntry: vi.fn(), - addPhoto: vi.fn(), addProviderPhoto: vi.fn(), linkPhotoToEntry: vi.fn(), uploadGalleryPhotos: vi.fn(), - addProviderPhotoToGallery: vi.fn(), unlinkPhotoFromEntry: vi.fn(), deleteGalleryPhoto: vi.fn(), setPhotoProvider: vi.fn(), - updatePhoto: vi.fn(), deletePhoto: vi.fn(), addContributor: vi.fn(), updateContributorRole: vi.fn(), removeContributor: vi.fn(), - getSuggestions: vi.fn(), listUserTrips: vi.fn(), - }, -})); -vi.mock('../../src/services/journeyService', () => jsvc); - -const { sharesvc } = vi.hoisted(() => ({ - sharesvc: { - createOrUpdateJourneyShareLink: vi.fn(), getJourneyShareLink: vi.fn(), deleteJourneyShareLink: vi.fn(), - getPublicJourney: vi.fn(), validateShareTokenForPhoto: vi.fn(), validateShareTokenForAsset: vi.fn(), - }, -})); -vi.mock('../../src/services/journeyShareService', () => sharesvc); - -import journeyRoutes from '../../src/routes/journey'; -import journeyPublicRoutes from '../../src/routes/journeyPublic'; -import { JourneyModule } from '../../src/nest/journey/journey.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; -import { ADDON_IDS } from '../../src/addons'; - -describe('C4 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - // Mirror the app.ts mount gate so both stacks 404 when the addon is off. - app.use('/api/journeys', (_req, res, next) => { - if (!isAddonEnabled(ADDON_IDS.JOURNEY)) return res.status(404).json({ error: 'Journey addon is not enabled' }); - next(); - }, journeyRoutes); - app.use('/api/public/journey', journeyPublicRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [JourneyModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - jsvc.listJourneys.mockReturnValue([{ id: 1, title: 'J' }]); - jsvc.createJourney.mockReturnValue({ id: 9, title: 'J' }); - jsvc.getSuggestions.mockReturnValue([{ id: 1 }]); - jsvc.listUserTrips.mockReturnValue([{ id: 2 }]); - jsvc.getJourneyFull.mockReturnValue({ id: 9, title: 'J' }); - jsvc.updateJourney.mockReturnValue({ id: 9, title: 'J2' }); - jsvc.deleteJourney.mockReturnValue(true); - jsvc.addTripToJourney.mockReturnValue(true); - jsvc.removeTripFromJourney.mockReturnValue(true); - jsvc.listEntries.mockReturnValue([{ id: 1 }]); - jsvc.createEntry.mockReturnValue({ id: 3 }); - jsvc.updateEntry.mockReturnValue({ id: 3 }); - jsvc.deleteEntry.mockReturnValue(true); - jsvc.reorderEntries.mockReturnValue(true); - jsvc.addProviderPhoto.mockReturnValue({ id: 5 }); - jsvc.linkPhotoToEntry.mockReturnValue({ id: 5 }); - jsvc.addProviderPhotoToGallery.mockReturnValue({ id: 5 }); - jsvc.unlinkPhotoFromEntry.mockReturnValue(true); - jsvc.deleteGalleryPhoto.mockReturnValue({ id: 5, file_path: null }); - jsvc.updatePhoto.mockReturnValue({ id: 5 }); - jsvc.deletePhoto.mockReturnValue({ id: 5, file_path: null }); - jsvc.addContributor.mockReturnValue(true); - jsvc.updateContributorRole.mockReturnValue(true); - jsvc.removeContributor.mockReturnValue(true); - jsvc.updateJourneyPreferences.mockReturnValue({ ok: true }); - sharesvc.getJourneyShareLink.mockReturnValue({ token: 'abc' }); - sharesvc.createOrUpdateJourneyShareLink.mockReturnValue({ token: 'abc' }); - sharesvc.deleteJourneyShareLink.mockReturnValue(true); - sharesvc.getPublicJourney.mockReturnValue({ id: 9 }); - }); - - beforeEach(() => { - isAddonEnabled.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('404 when the Journey addon is disabled', () => { - isAddonEnabled.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/journeys' }); - }); - - it('GET /journeys', () => expectParity(expressServer, nestServer, { path: '/api/journeys' })); - it('POST /journeys (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: { title: 'J' } })); - it('POST /journeys 400 no title', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys', body: {} })); - it('GET /journeys/suggestions', () => expectParity(expressServer, nestServer, { path: '/api/journeys/suggestions' })); - it('GET /journeys/available-trips', () => expectParity(expressServer, nestServer, { path: '/api/journeys/available-trips' })); - - it('PATCH /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: { title: 'x' } })); - it('PATCH /journeys/entries/:id 404', () => { - jsvc.updateEntry.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/entries/3', body: {} }); - }); - it('DELETE /journeys/entries/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3' })); - it('POST /journeys/entries/:id/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich', asset_ids: ['a', 'b'] } })); - it('POST /journeys/entries/:id/provider-photos 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/provider-photos', body: { provider: 'immich' } })); - it('POST /journeys/entries/:id/link-photo (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: { journey_photo_id: 5 } })); - it('POST /journeys/entries/:id/link-photo 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/entries/3/link-photo', body: {} })); - it('DELETE /journeys/entries/:id/photos/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/entries/3/photos/7' })); - it('PATCH /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/photos/5', body: { caption: 'c' } })); - it('DELETE /journeys/photos/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/photos/5' })); - - it('POST /journeys/:id/gallery/provider-photos batch', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/gallery/provider-photos', body: { provider: 'immich', asset_ids: ['a'] } })); - it('DELETE /journeys/:id/gallery/:pid (204)', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/gallery/7' })); - - it('GET /journeys/:id', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9' })); - it('GET /journeys/:id 404', () => { - jsvc.getJourneyFull.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { path: '/api/journeys/9' }); - }); - it('PATCH /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9', body: { title: 'J2' } })); - it('DELETE /journeys/:id', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9' })); - - it('POST /journeys/:id/trips (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: { trip_id: 2 } })); - it('POST /journeys/:id/trips 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/trips', body: {} })); - it('DELETE /journeys/:id/trips/:tripId', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/trips/2' })); - - it('GET /journeys/:id/entries', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/entries' })); - it('POST /journeys/:id/entries (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: { entry_date: '2026-01-01' } })); - it('POST /journeys/:id/entries 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/entries', body: {} })); - it('PUT /journeys/:id/entries/reorder', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: [1, 2] } })); - it('PUT /journeys/:id/entries/reorder 400', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/journeys/9/entries/reorder', body: { orderedIds: 'x' } })); - - it('POST /journeys/:id/contributors (201)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: { user_id: 2 } })); - it('POST /journeys/:id/contributors 400', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/contributors', body: {} })); - it('PATCH /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/contributors/2', body: { role: 'editor' } })); - it('DELETE /journeys/:id/contributors/:uid', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/contributors/2' })); - - it('PATCH /journeys/:id/preferences', () => expectParity(expressServer, nestServer, { method: 'patch', path: '/api/journeys/9/preferences', body: { theme: 'dark' } })); - it('GET /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/journeys/9/share-link' })); - it('POST /journeys/:id/share-link (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/journeys/9/share-link', body: { share_timeline: true } })); - it('DELETE /journeys/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/journeys/9/share-link' })); - - // Public - it('GET /public/journey/:token', () => expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' })); - it('GET /public/journey/:token 404', () => { - sharesvc.getPublicJourney.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { path: '/api/public/journey/tok' }); - }); -}); diff --git a/server/tests/parity/l2.parity.test.ts b/server/tests/parity/l2.parity.test.ts deleted file mode 100644 index bdf822d8..00000000 --- a/server/tests/parity/l2.parity.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * L2 parity — airports + public config + system notices. - * - * Fires the same request at the legacy Express routes and the migrated Nest - * controllers with the shared services mocked identically for both, then asserts - * the responses are client-identical (status + body). This is the gate before - * the prefixes are flipped to Nest: any difference here is a framework-layer - * regression (routing, error envelope, status), which a migration must not cause. - * - * Auth is neutralised the same way for both apps — `verifyJwtAndLoadUser` / - * `extractToken` are stubbed so the real Nest guard and the Express middleware - * both authenticate the same fixed user. Auth behaviour itself is covered by the - * per-module e2e tests. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' }, -})); - -// The services under test are mocked below, so no real DB is needed. Stubbing -// the connection keeps the legacy database.ts init (and its lazy backfill -// require) out of the parity run, which otherwise clashes with the mocked -// airportService module. -vi.mock('../../src/db/database', () => ({ db: {}, closeDb: () => {}, reinitialize: () => {} })); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'parity-token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { mockSearch, mockFindByIata } = vi.hoisted(() => ({ mockSearch: vi.fn(), mockFindByIata: vi.fn() })); -vi.mock('../../src/services/airportService', async (importActual) => { - const actual = await importActual(); - return { ...actual, searchAirports: mockSearch, findByIata: mockFindByIata }; -}); - -const { mockGetActive, mockDismiss } = vi.hoisted(() => ({ mockGetActive: vi.fn(), mockDismiss: vi.fn() })); -vi.mock('../../src/systemNotices/service', () => ({ - getActiveNoticesFor: mockGetActive, - dismissNotice: mockDismiss, -})); - -import airportsRoutes from '../../src/routes/airports'; -import publicConfigRoutes from '../../src/routes/publicConfig'; -import systemNoticesRoutes from '../../src/routes/systemNotices'; -import { AirportsModule } from '../../src/nest/airports/airports.module'; -import { ConfigModule } from '../../src/nest/config/config.module'; -import { SystemNoticesModule } from '../../src/nest/system-notices/system-notices.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -const BER = { - iata: 'BER', icao: 'EDDB', name: 'Berlin Brandenburg', city: 'Berlin', - country: 'DE', lat: 52.36, lng: 13.5, tz: 'Europe/Berlin', -}; -const notice = { - id: 'welcome', display: 'modal', severity: 'info', - titleKey: 'notice.welcome.title', bodyKey: 'notice.welcome.body', dismissible: true, -}; - -describe('L2 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/airports', airportsRoutes); - app.use('/api/config', publicConfigRoutes); - app.use('/api/system-notices', systemNoticesRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ - imports: [AirportsModule, ConfigModule, SystemNoticesModule], - }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - mockSearch.mockReturnValue([BER]); - mockFindByIata.mockImplementation((code: string) => (code === 'BER' ? BER : null)); - mockGetActive.mockReturnValue([notice]); - mockDismiss.mockImplementation((_userId: number, id: string) => id === 'welcome'); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /api/airports/search with a query', () => - expectParity(expressServer, nestServer, { path: '/api/airports/search', query: { q: 'ber' } })); - - it('GET /api/airports/search without a query', () => - expectParity(expressServer, nestServer, { path: '/api/airports/search' })); - - it('GET /api/airports/:iata found', () => - expectParity(expressServer, nestServer, { path: '/api/airports/BER' })); - - it('GET /api/airports/:iata not found (404)', () => - expectParity(expressServer, nestServer, { path: '/api/airports/ZZZ' })); - - it('GET /api/config (public)', () => - expectParity(expressServer, nestServer, { path: '/api/config' })); - - it('GET /api/system-notices/active', () => - expectParity(expressServer, nestServer, { path: '/api/system-notices/active' })); - - it('POST /api/system-notices/:id/dismiss success (204)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/welcome/dismiss' })); - - it('POST /api/system-notices/:id/dismiss not found (404)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/system-notices/nope/dismiss' })); -}); diff --git a/server/tests/parity/l20-share.parity.test.ts b/server/tests/parity/l20-share.parity.test.ts deleted file mode 100644 index c4ae28fc..00000000 --- a/server/tests/parity/l20-share.parity.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * C5 parity — trip share links + the public shared-trip read. - * - * Same request at the legacy Express /api route and the migrated Nest - * controllers, with shareService, the permission check, the trip-access lookup - * and auth mocked identically. Pins routing, trip-access 404, permission 403, - * the create-201-vs-update-200 split and the unguarded public 404/JSON. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -const { canAccessTrip } = vi.hoisted(() => ({ canAccessTrip: vi.fn() })); -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - canAccessTrip, closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { shareSvc } = vi.hoisted(() => ({ - shareSvc: { createOrUpdateShareLink: vi.fn(), getShareLink: vi.fn(), deleteShareLink: vi.fn(), getSharedTripData: vi.fn() }, -})); -vi.mock('../../src/services/shareService', () => shareSvc); - -import shareRoutes from '../../src/routes/share'; -import { ShareModule } from '../../src/nest/share/share.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C5 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api', shareRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [ShareModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - shareSvc.getShareLink.mockReturnValue({ token: 't', share_map: 1 }); - shareSvc.getSharedTripData.mockReturnValue({ trip: { id: 9 } }); - }); - - beforeEach(() => { - canAccessTrip.mockReturnValue({ user_id: 1 }); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('POST /trips/:id/share-link (201 created)', () => { - shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: true }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: { share_map: true } }); - }); - it('POST /trips/:id/share-link (200 update)', () => { - shareSvc.createOrUpdateShareLink.mockReturnValue({ token: 't', created: false }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} }); - }); - it('POST /trips/:id/share-link 404 no access', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} }); - }); - it('POST /trips/:id/share-link 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/share-link', body: {} }); - }); - it('GET /trips/:id/share-link', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' })); - it('GET /trips/:id/share-link null token', () => { - shareSvc.getShareLink.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' }); - }); - it('GET /trips/:id/share-link 404 no access', () => { - canAccessTrip.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/share-link' }); - }); - it('DELETE /trips/:id/share-link', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' })); - it('DELETE /trips/:id/share-link 403', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/trips/5/share-link' }); - }); - - it('GET /shared/:token', () => expectParity(expressServer, nestServer, { path: '/api/shared/tok' })); - it('GET /shared/:token 404', () => { - shareSvc.getSharedTripData.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(expressServer, nestServer, { path: '/api/shared/bad' }); - }); -}); diff --git a/server/tests/parity/l21-settings.parity.test.ts b/server/tests/parity/l21-settings.parity.test.ts deleted file mode 100644 index 529f96e9..00000000 --- a/server/tests/parity/l21-settings.parity.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * C6 parity — user settings. - * - * Same request at the legacy Express /api/settings route and the migrated Nest - * controller, with settingsService and auth mocked identically. Pins routing, - * the 400 guards, the masked-sentinel no-op and the bulk 200. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { settingsSvc } = vi.hoisted(() => ({ - settingsSvc: { getUserSettings: vi.fn(), upsertSetting: vi.fn(), bulkUpsertSettings: vi.fn() }, -})); -vi.mock('../../src/services/settingsService', () => settingsSvc); - -import settingsRoutes from '../../src/routes/settings'; -import { SettingsModule } from '../../src/nest/settings/settings.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C6 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/settings', settingsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [SettingsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - settingsSvc.getUserSettings.mockReturnValue({ theme: 'dark' }); - settingsSvc.bulkUpsertSettings.mockReturnValue(2); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /settings', () => expectParity(expressServer, nestServer, { path: '/api/settings' })); - it('PUT /settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'theme', value: 'dark' } })); - it('PUT /settings 400 no key', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { value: 'x' } })); - it('PUT /settings masked sentinel no-op', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/settings', body: { key: 'k', value: '••••••••' } })); - it('POST /settings/bulk (200)', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: { settings: { a: 1, b: 2 } } })); - it('POST /settings/bulk 400 no object', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/settings/bulk', body: {} })); -}); diff --git a/server/tests/parity/l22-backup.parity.test.ts b/server/tests/parity/l22-backup.parity.test.ts deleted file mode 100644 index 3e25329e..00000000 --- a/server/tests/parity/l22-backup.parity.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * C7 parity — backup (admin-only). - * - * Same request at the legacy Express /api/backup route and the migrated Nest - * controller, with backupService, auditLog and auth mocked identically (the - * fixed user is an admin so both the legacy adminOnly and the Nest AdminGuard - * pass). Multipart upload + res.download success differ per framework, so this - * pins routing, the rate-limit 429, filename 400/404, restore status mapping - * and the auto-settings/list/delete JSON. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedAdmin; - next(); - }, - adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedAdmin, -})); - -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); - -const { backupSvc } = vi.hoisted(() => ({ - backupSvc: { - listBackups: vi.fn(), createBackup: vi.fn(), restoreFromZip: vi.fn(), getAutoSettings: vi.fn(), - updateAutoSettings: vi.fn(), deleteBackup: vi.fn(), isValidBackupFilename: vi.fn(), backupFilePath: vi.fn(), - backupFileExists: vi.fn(), checkRateLimit: vi.fn(), getUploadTmpDir: () => '/tmp', BACKUP_RATE_WINDOW: 3600000, - MAX_BACKUP_UPLOAD_SIZE: 1024, - }, -})); -vi.mock('../../src/services/backupService', () => backupSvc); - -import backupRoutes from '../../src/routes/backup'; -import { BackupModule } from '../../src/nest/backup/backup.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('C7 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/backup', backupRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [BackupModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - backupSvc.listBackups.mockReturnValue([{ filename: 'a.zip', size: 1 }]); - backupSvc.createBackup.mockResolvedValue({ filename: 'b.zip', size: 10 }); - backupSvc.getAutoSettings.mockReturnValue({ settings: { enabled: true }, timezone: 'UTC' }); - backupSvc.updateAutoSettings.mockReturnValue({ enabled: true, interval: 'daily', keep_days: 7 }); - backupSvc.restoreFromZip.mockResolvedValue({ success: true }); - }); - - beforeEach(() => { - backupSvc.isValidBackupFilename.mockReturnValue(true); - backupSvc.backupFileExists.mockReturnValue(true); - backupSvc.checkRateLimit.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /backup/list', () => expectParity(expressServer, nestServer, { path: '/api/backup/list' })); - it('POST /backup/create', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' })); - it('POST /backup/create 429 rate-limited', () => { - backupSvc.checkRateLimit.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/create' }); - }); - it('GET /backup/download/:f 400 invalid', () => { - backupSvc.isValidBackupFilename.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/backup/download/bad' }); - }); - it('GET /backup/download/:f 404 missing', () => { - backupSvc.backupFileExists.mockReturnValue(false); - return expectParity(expressServer, nestServer, { path: '/api/backup/download/x.zip' }); - }); - it('POST /backup/restore/:f', () => expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' })); - it('POST /backup/restore/:f 400 invalid', () => { - backupSvc.isValidBackupFilename.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/bad' }); - }); - it('POST /backup/restore/:f maps the service status', () => { - backupSvc.restoreFromZip.mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' }).mockResolvedValueOnce({ success: false, status: 422, error: 'bad zip' }); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/backup/restore/x.zip' }); - }); - it('GET /backup/auto-settings', () => expectParity(expressServer, nestServer, { path: '/api/backup/auto-settings' })); - it('PUT /backup/auto-settings', () => expectParity(expressServer, nestServer, { method: 'put', path: '/api/backup/auto-settings', body: { enabled: true } })); - it('DELETE /backup/:f', () => expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' })); - it('DELETE /backup/:f 404', () => { - backupSvc.backupFileExists.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'delete', path: '/api/backup/x.zip' }); - }); -}); diff --git a/server/tests/parity/l23-auth.parity.test.ts b/server/tests/parity/l23-auth.parity.test.ts deleted file mode 100644 index 3a50bff5..00000000 --- a/server/tests/parity/l23-auth.parity.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * A1 parity — auth (public flows + authenticated account/MFA/token endpoints). - * - * Same request at the legacy Express /api/auth route and the migrated Nest - * controllers, with authService, the cookie service, notifications, auditLog and - * auth middleware mocked identically. Cookies are a header side-effect (not - * compared) and the rate-limit 429 + multipart avatar are covered in the unit - * tests; this pins routing, status codes (register/mcp-token 201 vs the rest - * 200), the login/reset MFA branches and the {error,status} envelopes. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); }, - optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); }, - demoUploadBlock: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn(), clearAuthCookie: vi.fn() })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: vi.fn(() => '1.2.3.4') })); -vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://x', sendPasswordResetEmail: vi.fn().mockResolvedValue({ delivered: true }) })); - -const { authSvc } = vi.hoisted(() => ({ - authSvc: { - getAppConfig: vi.fn(), demoLogin: vi.fn(), validateInviteToken: vi.fn(), registerUser: vi.fn(), loginUser: vi.fn(), - requestPasswordReset: vi.fn(), resetPassword: vi.fn(), verifyMfaLogin: vi.fn(), getCurrentUser: vi.fn(), - changePassword: vi.fn(), deleteAccount: vi.fn(), updateMapsKey: vi.fn(), updateApiKeys: vi.fn(), updateSettings: vi.fn(), - getSettings: vi.fn(), saveAvatar: vi.fn(), deleteAvatar: vi.fn(), listUsers: vi.fn(), validateKeys: vi.fn(), - getAppSettings: vi.fn(), updateAppSettings: vi.fn(), getTravelStats: vi.fn(), setupMfa: vi.fn(), enableMfa: vi.fn(), - disableMfa: vi.fn(), listMcpTokens: vi.fn(), createMcpToken: vi.fn(), deleteMcpToken: vi.fn(), createWsToken: vi.fn(), - createResourceToken: vi.fn(), requestPasswordReset_unused: vi.fn(), - }, -})); -vi.mock('../../src/services/authService', () => authSvc); - -import authRoutes from '../../src/routes/auth'; -import { AuthModule } from '../../src/nest/auth/auth.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('A1 parity (Express vs Nest)', () => { - let ex: express.Express; - let ne: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/auth', authRoutes); - return app; - } - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [AuthModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - ex = buildExpress(); - nestApp = await buildNest(); - ne = nestApp.getHttpServer(); - authSvc.getAppConfig.mockReturnValue({ version: '3', features: {} }); - authSvc.demoLogin.mockReturnValue({ token: 'tk', user: fixedUser }); - authSvc.validateInviteToken.mockReturnValue({ valid: true, max_uses: 1, used_count: 0, expires_at: null }); - authSvc.registerUser.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1, auditDetails: {} }); - authSvc.loginUser.mockReturnValue({ token: 'tk', user: fixedUser }); - authSvc.requestPasswordReset.mockReturnValue({ reason: 'no_user', userId: null }); - authSvc.resetPassword.mockReturnValue({ userId: 1 }); - authSvc.verifyMfaLogin.mockReturnValue({ token: 'tk', user: fixedUser, auditUserId: 1 }); - authSvc.getCurrentUser.mockReturnValue({ id: 1, email: 'u@example.test' }); - authSvc.changePassword.mockReturnValue({}); - authSvc.deleteAccount.mockReturnValue({}); - authSvc.updateMapsKey.mockReturnValue({ success: true }); - authSvc.updateApiKeys.mockReturnValue({ success: true }); - authSvc.updateSettings.mockReturnValue({ success: true, user: fixedUser }); - authSvc.getSettings.mockReturnValue({ settings: { theme: 'dark' } }); - authSvc.deleteAvatar.mockResolvedValue({ success: true }); - authSvc.listUsers.mockReturnValue([{ id: 1 }]); - authSvc.validateKeys.mockResolvedValue({ maps: true, weather: true, maps_details: {} }); - authSvc.getAppSettings.mockReturnValue({ data: { foo: 'bar' } }); - authSvc.updateAppSettings.mockReturnValue({ auditSummary: {}, auditDebugDetails: {} }); - authSvc.getTravelStats.mockReturnValue({ trips: 5 }); - authSvc.enableMfa.mockReturnValue({ mfa_enabled: true, backup_codes: ['a'] }); - authSvc.disableMfa.mockReturnValue({ mfa_enabled: false }); - authSvc.listMcpTokens.mockReturnValue([{ id: 't1' }]); - authSvc.createMcpToken.mockReturnValue({ token: 'mcp_x' }); - authSvc.deleteMcpToken.mockReturnValue({}); - authSvc.createWsToken.mockReturnValue({ token: 'ws_x' }); - authSvc.createResourceToken.mockReturnValue({ token: 'rt_x' }); - }); - - afterAll(async () => { await nestApp.close(); }); - - it('GET /app-config', () => expectParity(ex, ne, { path: '/api/auth/app-config' })); - it('POST /demo-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/demo-login' })); - it('GET /invite/:token', () => expectParity(ex, ne, { path: '/api/auth/invite/tok' })); - it('POST /register (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/register', body: { email: 'a@b.c', password: 'p' } })); - it('POST /login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: { email: 'a@b.c', password: 'p' } })); - it('POST /login mfa branch', () => { - authSvc.loginUser.mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' }).mockReturnValueOnce({ mfa_required: true, mfa_token: 'mt' }); - return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} }); - }); - it('POST /login 401', () => { - authSvc.loginUser.mockReturnValueOnce({ error: 'Bad creds', status: 401 }).mockReturnValueOnce({ error: 'Bad creds', status: 401 }); - return expectParity(ex, ne, { method: 'post', path: '/api/auth/login', body: {} }); - }); - it('POST /forgot-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/forgot-password', body: { email: 'a@b.c' } })); - it('POST /reset-password', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: { token: 't', password: 'p' } })); - it('POST /reset-password mfa branch', () => { - authSvc.resetPassword.mockReturnValueOnce({ mfa_required: true }).mockReturnValueOnce({ mfa_required: true }); - return expectParity(ex, ne, { method: 'post', path: '/api/auth/reset-password', body: {} }); - }); - it('POST /logout', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/logout' })); - it('POST /mfa/verify-login', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/verify-login', body: { mfa_token: 't', code: '1' } })); - - it('GET /me', () => expectParity(ex, ne, { path: '/api/auth/me' })); - it('GET /me 404', () => { - authSvc.getCurrentUser.mockReturnValueOnce(undefined).mockReturnValueOnce(undefined); - return expectParity(ex, ne, { path: '/api/auth/me' }); - }); - it('PUT /me/password', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/password', body: { current_password: 'a', new_password: 'b' } })); - it('DELETE /me', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/me' })); - it('PUT /me/maps-key', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/maps-key', body: { maps_api_key: 'k' } })); - it('PUT /me/api-keys', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/api-keys', body: {} })); - it('PUT /me/settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/me/settings', body: {} })); - it('GET /me/settings', () => expectParity(ex, ne, { path: '/api/auth/me/settings' })); - it('DELETE /avatar', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/avatar' })); - it('GET /users', () => expectParity(ex, ne, { path: '/api/auth/users' })); - it('GET /validate-keys', () => expectParity(ex, ne, { path: '/api/auth/validate-keys' })); - it('GET /app-settings', () => expectParity(ex, ne, { path: '/api/auth/app-settings' })); - it('PUT /app-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/auth/app-settings', body: {} })); - it('GET /travel-stats', () => expectParity(ex, ne, { path: '/api/auth/travel-stats' })); - it('POST /mfa/enable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/enable', body: { code: '1' } })); - it('POST /mfa/disable', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mfa/disable', body: {} })); - it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/auth/mcp-tokens' })); - it('POST /mcp-tokens (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/mcp-tokens', body: { name: 'CLI' } })); - it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/auth/mcp-tokens/t1' })); - it('POST /ws-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/ws-token' })); - it('POST /resource-token', () => expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: { purpose: 'download' } })); - it('POST /resource-token 503', () => { - authSvc.createResourceToken.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(ex, ne, { method: 'post', path: '/api/auth/resource-token', body: {} }); - }); -}); diff --git a/server/tests/parity/l24-oidc.parity.test.ts b/server/tests/parity/l24-oidc.parity.test.ts deleted file mode 100644 index cb6ce2a2..00000000 --- a/server/tests/parity/l24-oidc.parity.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -/** - * A2 parity — OIDC SSO. - * - * Same request at the legacy Express /api/auth/oidc route and the migrated Nest - * controller, with oidcService, authService.resolveAuthToggles, the cookie - * service and getAppUrl mocked identically. Redirects compare by status (302, - * same Location by construction); the disabled/not-configured/exchange branches - * compare the JSON bodies. supertest does not follow redirects, so 302 bodies - * stay empty on both sides. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -vi.mock('../../src/services/cookie', () => ({ setAuthCookie: vi.fn() })); -vi.mock('../../src/services/notifications', () => ({ getAppUrl: () => 'https://app' })); - -const { toggles } = vi.hoisted(() => ({ toggles: { oidc_login: true } })); -vi.mock('../../src/services/authService', () => ({ resolveAuthToggles: () => toggles })); - -const { oidcSvc } = vi.hoisted(() => ({ - oidcSvc: { - getOidcConfig: vi.fn(), discover: vi.fn(), createState: vi.fn(), consumeState: vi.fn(), createAuthCode: vi.fn(), - consumeAuthCode: vi.fn(), exchangeCodeForToken: vi.fn(), getUserInfo: vi.fn(), verifyIdToken: vi.fn(), - findOrCreateUser: vi.fn(), touchLastLogin: vi.fn(), generateToken: vi.fn(), frontendUrl: (p: string) => 'https://app' + p, - }, -})); -vi.mock('../../src/services/oidcService', () => oidcSvc); - -import oidcRoutes from '../../src/routes/oidc'; -import { OidcModule } from '../../src/nest/oidc/oidc.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('A2 parity (Express vs Nest)', () => { - let ex: express.Express; - let ne: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/auth/oidc', oidcRoutes); - return app; - } - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [OidcModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - ex = buildExpress(); - nestApp = await buildNest(); - ne = nestApp.getHttpServer(); - oidcSvc.getOidcConfig.mockReturnValue({ issuer: 'https://idp', clientId: 'c', clientSecret: 's', discoveryUrl: null }); - oidcSvc.discover.mockResolvedValue({ authorization_endpoint: 'https://idp/auth', userinfo_endpoint: 'https://idp/ui', issuer: 'https://idp' }); - oidcSvc.createState.mockReturnValue({ state: 'st', codeChallenge: 'cc' }); - oidcSvc.consumeState.mockReturnValue({ redirectUri: 'https://app/api/auth/oidc/callback', codeVerifier: 'cv' }); - oidcSvc.consumeAuthCode.mockReturnValue({ token: 'jwt' }); - }); - - beforeEach(() => { toggles.oidc_login = true; }); - - afterAll(async () => { await nestApp.close(); }); - - it('GET /login redirects (302)', () => expectParity(ex, ne, { path: '/api/auth/oidc/login' })); - it('GET /login 403 when SSO disabled', () => { - toggles.oidc_login = false; - return expectParity(ex, ne, { path: '/api/auth/oidc/login' }); - }); - it('GET /login 400 not configured', () => { - oidcSvc.getOidcConfig.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(ex, ne, { path: '/api/auth/oidc/login' }); - }); - it('GET /callback redirects on missing params', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback' })); - it('GET /callback redirects with provider error', () => expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { error: 'access_denied' } })); - it('GET /callback redirects on invalid state', () => { - oidcSvc.consumeState.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } }); - }); - it('GET /callback completes the full flow with an auth-code redirect', () => { - // Drive the whole success chain so the service wrappers (exchange/verify/ - // userinfo/provision/token/auth-code) run on both stacks. - oidcSvc.consumeState.mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' }).mockReturnValueOnce({ redirectUri: 'https://app/cb', codeVerifier: 'cv' }); - oidcSvc.exchangeCodeForToken.mockResolvedValue({ _ok: true, access_token: 'at', id_token: 'it' }); - oidcSvc.verifyIdToken.mockResolvedValue({ ok: true, claims: { sub: 'u1' } }); - oidcSvc.getUserInfo.mockResolvedValue({ email: 'a@b.c', sub: 'u1' }); - oidcSvc.findOrCreateUser.mockReturnValue({ user: { id: 1 } }); - oidcSvc.generateToken.mockReturnValue('jwt'); - oidcSvc.createAuthCode.mockReturnValue('ac'); - return expectParity(ex, ne, { path: '/api/auth/oidc/callback', query: { code: 'c', state: 's' } }); - }); - - it('GET /exchange 400 without a code', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange' })); - it('GET /exchange 400 on an invalid code', () => { - oidcSvc.consumeAuthCode.mockReturnValueOnce({ error: 'invalid_code' }).mockReturnValueOnce({ error: 'invalid_code' }); - return expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'bad' } }); - }); - it('GET /exchange sets cookie + returns token', () => expectParity(ex, ne, { path: '/api/auth/oidc/exchange', query: { code: 'good' } })); -}); diff --git a/server/tests/parity/l25-oauth.parity.test.ts b/server/tests/parity/l25-oauth.parity.test.ts deleted file mode 100644 index 3cacfe7d..00000000 --- a/server/tests/parity/l25-oauth.parity.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * A3 parity — OAuth 2.1 server (public token/userinfo/revoke + the SPA's - * /api/oauth management endpoints). - * - * Same request at the legacy Express routers and the migrated Nest controllers, - * with oauthService, the MCP addon gate, getMcpSafeUrl, auditLog and auth - * mocked identically. The Nest app gets cookie-parser and the cookie-auth - * routes are sent a trek_session cookie (the legacy mocks ignore it). Pins the - * grant branches, RFC error bodies, the empty-404 gate and the consent redirect. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import cookieParser from 'cookie-parser'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' } })); -const COOKIE = { Cookie: 'trek_session=x' }; - -vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} })); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); }, - optionalAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); }, - requireCookieAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedUser; next(); }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { isAddonEnabled } = vi.hoisted(() => ({ isAddonEnabled: vi.fn(() => true) })); -vi.mock('../../src/services/adminService', () => ({ isAddonEnabled })); -vi.mock('../../src/services/notifications', () => ({ getMcpSafeUrl: () => 'https://app' })); -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logWarn: vi.fn() })); - -const { oauthSvc } = vi.hoisted(() => ({ - oauthSvc: { - validateAuthorizeRequest: vi.fn(), createAuthCode: vi.fn(), consumeAuthCode: vi.fn(), saveConsent: vi.fn(), - issueTokens: vi.fn(), issueClientCredentialsToken: vi.fn(), refreshTokens: vi.fn(), revokeToken: vi.fn(), - verifyPKCE: vi.fn(), authenticateClient: vi.fn(), listOAuthClients: vi.fn(), createOAuthClient: vi.fn(), - deleteOAuthClient: vi.fn(), rotateOAuthClientSecret: vi.fn(), listOAuthSessions: vi.fn(), revokeSession: vi.fn(), - getUserByAccessToken: vi.fn(), - }, -})); -vi.mock('../../src/services/oauthService', () => oauthSvc); - -import { oauthPublicRouter, oauthApiRouter } from '../../src/routes/oauth'; -import { OauthModule } from '../../src/nest/oauth/oauth.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('A3 parity (Express vs Nest)', () => { - let ex: express.Express; - let ne: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use(cookieParser()); - app.use('/api/oauth', oauthApiRouter); - app.use('/', oauthPublicRouter); - return app; - } - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [OauthModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.use(cookieParser()); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - ex = buildExpress(); - nestApp = await buildNest(); - ne = nestApp.getHttpServer(); - oauthSvc.getUserByAccessToken.mockReturnValue({ user: { id: 1, email: 'a@b.c', username: 'u' } }); - oauthSvc.authenticateClient.mockReturnValue({ id: 'c', is_public: false, user_id: 1, allows_client_credentials: true, allowed_scopes: '["a","b"]' }); - oauthSvc.listOAuthClients.mockReturnValue([{ id: 'c1' }]); - oauthSvc.listOAuthSessions.mockReturnValue([{ id: 1 }]); - oauthSvc.createOAuthClient.mockReturnValue({ client_id: 'c1', client_secret: 's' }); - oauthSvc.deleteOAuthClient.mockReturnValue({}); - oauthSvc.revokeSession.mockReturnValue({}); - oauthSvc.validateAuthorizeRequest.mockReturnValue({ valid: true, scopes: ['s'], resource: null, client_name: 'CLI', allowed_scopes: ['s'] }); - oauthSvc.createAuthCode.mockReturnValue('the_code'); - }); - - beforeEach(() => { isAddonEnabled.mockReturnValue(true); }); - - afterAll(async () => { await nestApp.close(); }); - - // Public — token - it('POST /oauth/token 401 without client_id', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: {} })); - it('POST /oauth/token unsupported grant', () => expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c', grant_type: 'password' } })); - it('POST /oauth/token authorization_code invalid_grant', () => { - oauthSvc.consumeAuthCode.mockReturnValueOnce(null).mockReturnValueOnce(null); - return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } }); - }); - it('POST /oauth/token authorization_code success', () => { - oauthSvc.consumeAuthCode.mockReturnValue({ clientId: 'c', redirectUri: 'u', userId: 1, scopes: ['s'], codeChallenge: 'cc', resource: null }); - oauthSvc.verifyPKCE.mockReturnValue(true); - oauthSvc.issueTokens.mockReturnValue({ access_token: 'at', token_type: 'Bearer', expires_in: 3600 }); - return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'authorization_code', client_id: 'c', code: 'x', redirect_uri: 'u', code_verifier: 'v' } }); - }); - it('POST /oauth/token client_credentials success', () => { - oauthSvc.issueClientCredentialsToken.mockReturnValue({ access_token: 'cc_at', token_type: 'Bearer' }); - return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { grant_type: 'client_credentials', client_id: 'c', client_secret: 's' } }); - }); - it('POST /oauth/token 404 when MCP disabled', () => { - isAddonEnabled.mockReturnValue(false); - return expectParity(ex, ne, { method: 'post', path: '/oauth/token', body: { client_id: 'c' } }); - }); - - // Public — userinfo + revoke - it('GET /oauth/userinfo 401 without Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo' })); - it('GET /oauth/userinfo with Bearer', () => expectParity(ex, ne, { path: '/oauth/userinfo', headers: { Authorization: 'Bearer tok' } })); - it('POST /oauth/revoke 400 without token', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { client_id: 'c' } })); - it('POST /oauth/revoke 200', () => expectParity(ex, ne, { method: 'post', path: '/oauth/revoke', body: { token: 't', client_id: 'c' } })); - - // API — validate / authorize / clients / sessions - it('GET /api/oauth/authorize/validate', () => expectParity(ex, ne, { path: '/api/oauth/authorize/validate', query: { response_type: 'code', client_id: 'c', redirect_uri: 'u', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256' }, headers: COOKIE })); - it('GET /api/oauth/authorize/validate 404 MCP off', () => { - isAddonEnabled.mockReturnValue(false); - return expectParity(ex, ne, { path: '/api/oauth/authorize/validate', headers: COOKIE }); - }); - it('POST /api/oauth/authorize denied redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: false } })); - it('POST /api/oauth/authorize approved redirect', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/authorize', headers: COOKIE, body: { client_id: 'c', redirect_uri: 'https://cb', scope: 's', code_challenge: 'cc', code_challenge_method: 'S256', approved: true } })); - it('GET /api/oauth/clients', () => expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE })); - it('POST /api/oauth/clients (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/oauth/clients', headers: COOKIE, body: { name: 'CLI', allowed_scopes: ['a'] } })); - it('DELETE /api/oauth/clients/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/clients/c1', headers: COOKIE })); - it('GET /api/oauth/sessions', () => expectParity(ex, ne, { path: '/api/oauth/sessions', headers: COOKIE })); - it('DELETE /api/oauth/sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/oauth/sessions/1', headers: COOKIE })); - it('GET /api/oauth/clients 403 MCP off', () => { - isAddonEnabled.mockReturnValue(false); - return expectParity(ex, ne, { path: '/api/oauth/clients', headers: COOKIE }); - }); -}); diff --git a/server/tests/parity/l26-admin.parity.test.ts b/server/tests/parity/l26-admin.parity.test.ts deleted file mode 100644 index 1d65e688..00000000 --- a/server/tests/parity/l26-admin.parity.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * A4 parity — admin control surface. - * - * Same request at the legacy Express /api/admin route and the migrated Nest - * controller, with adminService, the settings/MCP/notification-pref helpers, - * auditLog and auth mocked identically (the fixed user is an admin so both the - * legacy adminOnly and the Nest AdminGuard pass). Pins routing, the create-201 - * vs 200 split, the {error,status} envelopes and the validation 400s across a - * representative slice of each sub-domain. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedAdmin } = vi.hoisted(() => ({ fixedAdmin: { id: 1, username: 'a', email: 'a@example.test', role: 'admin' } })); - -vi.mock('../../src/db/database', () => ({ db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, closeDb: () => {}, reinitialize: () => {} })); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { user: unknown }).user = fixedAdmin; next(); }, - adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedAdmin, -})); - -vi.mock('../../src/services/auditLog', () => ({ writeAudit: vi.fn(), getClientIp: () => '1.2.3.4', logInfo: vi.fn() })); -vi.mock('../../src/mcp', () => ({ invalidateMcpSessions: vi.fn() })); -vi.mock('../../src/services/notificationPreferencesService', () => ({ getPreferencesMatrix: vi.fn(() => ({ matrix: {} })), setAdminPreferences: vi.fn() })); -vi.mock('../../src/services/settingsService', () => ({ getAdminUserDefaults: vi.fn(() => ({ theme: 'dark' })), setAdminUserDefaults: vi.fn() })); - -const { adminSvc } = vi.hoisted(() => ({ - adminSvc: { - listUsers: vi.fn(), createUser: vi.fn(), updateUser: vi.fn(), deleteUser: vi.fn(), getStats: vi.fn(), - getPermissions: vi.fn(), savePermissions: vi.fn(), getAuditLog: vi.fn(), getOidcSettings: vi.fn(), updateOidcSettings: vi.fn(), - saveDemoBaseline: vi.fn(), getGithubReleases: vi.fn(), checkVersion: vi.fn(), listInvites: vi.fn(), createInvite: vi.fn(), - deleteInvite: vi.fn(), getBagTracking: vi.fn(), updateBagTracking: vi.fn(), getPlacesPhotos: vi.fn(), updatePlacesPhotos: vi.fn(), - getPlacesAutocomplete: vi.fn(), updatePlacesAutocomplete: vi.fn(), getPlacesDetails: vi.fn(), updatePlacesDetails: vi.fn(), - getCollabFeatures: vi.fn(), updateCollabFeatures: vi.fn(), listPackingTemplates: vi.fn(), getPackingTemplate: vi.fn(), - createPackingTemplate: vi.fn(), updatePackingTemplate: vi.fn(), deletePackingTemplate: vi.fn(), createTemplateCategory: vi.fn(), - updateTemplateCategory: vi.fn(), deleteTemplateCategory: vi.fn(), createTemplateItem: vi.fn(), updateTemplateItem: vi.fn(), - deleteTemplateItem: vi.fn(), listAddons: vi.fn(), updateAddon: vi.fn(), listMcpTokens: vi.fn(), deleteMcpToken: vi.fn(), - listOAuthSessions: vi.fn(), revokeOAuthSession: vi.fn(), rotateJwtSecret: vi.fn(), - }, -})); -vi.mock('../../src/services/adminService', () => adminSvc); - -import adminRoutes from '../../src/routes/admin'; -import { AdminModule } from '../../src/nest/admin/admin.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('A4 parity (Express vs Nest)', () => { - let ex: express.Express; - let ne: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/admin', adminRoutes); - return app; - } - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [AdminModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - ex = buildExpress(); - nestApp = await buildNest(); - ne = nestApp.getHttpServer(); - adminSvc.listUsers.mockReturnValue([{ id: 1 }]); - adminSvc.createUser.mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }); - adminSvc.updateUser.mockReturnValue({ user: { id: 2 }, previousEmail: 'a@b.c', changed: ['role'] }); - adminSvc.deleteUser.mockReturnValue({ email: 'a@b.c' }); - adminSvc.getStats.mockReturnValue({ users: 3 }); - adminSvc.getPermissions.mockReturnValue({ permissions: {} }); - adminSvc.savePermissions.mockReturnValue({ permissions: { x: 1 }, skipped: [] }); - adminSvc.getAuditLog.mockReturnValue({ entries: [] }); - adminSvc.getOidcSettings.mockReturnValue({ issuer: '' }); - adminSvc.updateOidcSettings.mockReturnValue({}); - adminSvc.getGithubReleases.mockResolvedValue({ releases: [] }); - adminSvc.checkVersion.mockResolvedValue({ current: '3', latest: '3' }); - adminSvc.listInvites.mockReturnValue([]); - adminSvc.createInvite.mockReturnValue({ invite: { id: 5 }, inviteId: 5, uses: 1, expiresInDays: 7 }); - adminSvc.deleteInvite.mockReturnValue({}); - adminSvc.getBagTracking.mockReturnValue({ enabled: false }); - adminSvc.updateBagTracking.mockReturnValue({ enabled: true }); - adminSvc.updatePlacesPhotos.mockReturnValue({ enabled: true }); - adminSvc.getPlacesPhotos.mockReturnValue({ enabled: false }); - adminSvc.getPlacesAutocomplete.mockReturnValue({ enabled: false }); - adminSvc.updatePlacesAutocomplete.mockReturnValue({ enabled: true }); - adminSvc.getPlacesDetails.mockReturnValue({ enabled: false }); - adminSvc.updatePlacesDetails.mockReturnValue({ enabled: true }); - adminSvc.updatePackingTemplate.mockReturnValue({ id: 3, name: 'B2' }); - adminSvc.createTemplateCategory.mockReturnValue({ id: 4 }); - adminSvc.updateTemplateCategory.mockReturnValue({ id: 4 }); - adminSvc.deleteTemplateCategory.mockReturnValue({}); - adminSvc.updateTemplateItem.mockReturnValue({ id: 7 }); - adminSvc.deleteTemplateItem.mockReturnValue({}); - adminSvc.getCollabFeatures.mockReturnValue({ chat: true }); - adminSvc.updateCollabFeatures.mockReturnValue({ chat: false }); - adminSvc.listPackingTemplates.mockReturnValue([]); - adminSvc.getPackingTemplate.mockReturnValue({ id: 3 }); - adminSvc.createPackingTemplate.mockReturnValue({ id: 3, name: 'Beach' }); - adminSvc.deletePackingTemplate.mockReturnValue({ name: 'Beach' }); - adminSvc.createTemplateItem.mockReturnValue({ id: 7 }); - adminSvc.listAddons.mockReturnValue([{ id: 'mcp' }]); - adminSvc.updateAddon.mockReturnValue({ addon: { id: 'mcp', enabled: true }, auditDetails: {} }); - adminSvc.listMcpTokens.mockReturnValue([]); - adminSvc.deleteMcpToken.mockReturnValue({}); - adminSvc.listOAuthSessions.mockReturnValue([]); - adminSvc.revokeOAuthSession.mockReturnValue({}); - adminSvc.rotateJwtSecret.mockReturnValue({}); - }); - - beforeEach(() => { delete process.env.NODE_ENV; }); - - afterAll(async () => { await nestApp.close(); }); - - it('GET /users', () => expectParity(ex, ne, { path: '/api/admin/users' })); - it('POST /users (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: { email: 'a@b.c' } })); - it('POST /users error', () => { - adminSvc.createUser.mockReturnValueOnce({ error: 'taken', status: 409 }).mockReturnValueOnce({ error: 'taken', status: 409 }); - return expectParity(ex, ne, { method: 'post', path: '/api/admin/users', body: {} }); - }); - it('PUT /users/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/users/2', body: { role: 'admin' } })); - it('DELETE /users/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/users/2' })); - it('GET /stats', () => expectParity(ex, ne, { path: '/api/admin/stats' })); - it('GET /permissions', () => expectParity(ex, ne, { path: '/api/admin/permissions' })); - it('PUT /permissions 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: {} })); - it('PUT /permissions', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/permissions', body: { permissions: { x: 1 } } })); - it('GET /audit-log', () => expectParity(ex, ne, { path: '/api/admin/audit-log', query: { limit: '10' } })); - it('GET /oidc', () => expectParity(ex, ne, { path: '/api/admin/oidc' })); - it('PUT /oidc', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/oidc', body: { issuer: 'https://idp' } })); - it('POST /save-demo-baseline error', () => { - adminSvc.saveDemoBaseline.mockReturnValueOnce({ error: 'not demo', status: 400 }).mockReturnValueOnce({ error: 'not demo', status: 400 }); - return expectParity(ex, ne, { method: 'post', path: '/api/admin/save-demo-baseline' }); - }); - it('GET /github-releases', () => expectParity(ex, ne, { path: '/api/admin/github-releases' })); - it('GET /version-check', () => expectParity(ex, ne, { path: '/api/admin/version-check' })); - it('GET /notification-preferences', () => expectParity(ex, ne, { path: '/api/admin/notification-preferences' })); - it('GET /invites', () => expectParity(ex, ne, { path: '/api/admin/invites' })); - it('POST /invites (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/invites', body: { max_uses: 1 } })); - it('DELETE /invites/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/invites/5' })); - it('PUT /bag-tracking', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/bag-tracking', body: { enabled: true } })); - it('PUT /places-photos 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: 'yes' } })); - it('PUT /places-photos', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-photos', body: { enabled: true } })); - it('PUT /collab-features', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/collab-features', body: { chat: false } })); - it('GET /places-photos', () => expectParity(ex, ne, { path: '/api/admin/places-photos' })); - it('GET /places-autocomplete', () => expectParity(ex, ne, { path: '/api/admin/places-autocomplete' })); - it('PUT /places-autocomplete', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-autocomplete', body: { enabled: true } })); - it('GET /places-details', () => expectParity(ex, ne, { path: '/api/admin/places-details' })); - it('PUT /places-details', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/places-details', body: { enabled: true } })); - it('PUT /packing-templates/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3', body: { name: 'B2' } })); - it('POST /packing-templates/:id/categories (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories', body: { name: 'Cat' } })); - it('PUT /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/categories/4', body: { name: 'C2' } })); - it('DELETE /packing-templates/:t/categories/:c', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/categories/4' })); - it('PUT /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/packing-templates/3/items/7', body: { name: 'I2' } })); - it('DELETE /packing-templates/:t/items/:i', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3/items/7' })); - it('DELETE /mcp-tokens/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/mcp-tokens/t1' })); - it('PUT /notification-preferences', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/notification-preferences', body: {} })); - it('GET /packing-templates', () => expectParity(ex, ne, { path: '/api/admin/packing-templates' })); - it('GET /packing-templates/:id', () => expectParity(ex, ne, { path: '/api/admin/packing-templates/3' })); - it('POST /packing-templates (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates', body: { name: 'Beach' } })); - it('DELETE /packing-templates/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/packing-templates/3' })); - it('POST /packing-templates/:t/categories/:c/items (201)', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/packing-templates/3/categories/4/items', body: { name: 'Towel' } })); - it('GET /addons', () => expectParity(ex, ne, { path: '/api/admin/addons' })); - it('PUT /addons/:id', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/addons/mcp', body: { enabled: true } })); - it('GET /mcp-tokens', () => expectParity(ex, ne, { path: '/api/admin/mcp-tokens' })); - it('GET /oauth-sessions', () => expectParity(ex, ne, { path: '/api/admin/oauth-sessions' })); - it('DELETE /oauth-sessions/:id', () => expectParity(ex, ne, { method: 'delete', path: '/api/admin/oauth-sessions/3' })); - it('POST /rotate-jwt-secret', () => expectParity(ex, ne, { method: 'post', path: '/api/admin/rotate-jwt-secret' })); - it('GET /default-user-settings', () => expectParity(ex, ne, { path: '/api/admin/default-user-settings' })); - it('PUT /default-user-settings 400', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: [] })); - it('PUT /default-user-settings', () => expectParity(ex, ne, { method: 'put', path: '/api/admin/default-user-settings', body: { theme: 'dark' } })); -}); diff --git a/server/tests/parity/l3.parity.test.ts b/server/tests/parity/l3.parity.test.ts deleted file mode 100644 index 37d98b88..00000000 --- a/server/tests/parity/l3.parity.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * L3 parity — maps / geo. - * - * Fires the same request at the legacy Express /api/maps route and the migrated - * Nest controller with mapsService mocked identically for both, asserting - * client-identical status + body. Covers the JSON endpoints; the file-serving - * /place-photo/:placeId/bytes route is covered by the controller unit test. - * - * The per-endpoint kill-switches read app_settings; the stubbed DB returns no - * rows, so every switch reads as "enabled" — the disabled short-circuits are - * covered by the unit + e2e tests. Auth is neutralised identically for both apps. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'parity', email: 'parity@example.test', role: 'user' }, -})); - -// Stub DB: every app_settings lookup misses -> kill-switches read as enabled. -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'parity-token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { mocks } = vi.hoisted(() => ({ - mocks: { - searchPlaces: vi.fn(), - autocompletePlaces: vi.fn(), - getPlaceDetails: vi.fn(), - getPlaceDetailsExpanded: vi.fn(), - getPlacePhoto: vi.fn(), - reverseGeocode: vi.fn(), - resolveGoogleMapsUrl: vi.fn(), - }, -})); -vi.mock('../../src/services/mapsService', async (importActual) => { - const actual = await importActual(); - return { ...actual, ...mocks }; -}); - -import mapsRoutes from '../../src/routes/maps'; -import { MapsModule } from '../../src/nest/maps/maps.module'; -import { DatabaseModule } from '../../src/nest/database/database.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('L3 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/maps', mapsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, MapsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - vi.spyOn(console, 'error').mockImplementation(() => {}); - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - mocks.searchPlaces.mockResolvedValue({ places: [{ name: 'Berlin' }], source: 'osm' }); - mocks.autocompletePlaces.mockResolvedValue({ suggestions: [{ placeId: 'p', mainText: 'Berlin', secondaryText: 'DE' }], source: 'osm' }); - mocks.getPlaceDetails.mockResolvedValue({ place: { id: 'p1', name: 'Spot' } }); - mocks.getPlaceDetailsExpanded.mockResolvedValue({ place: { id: 'p1', name: 'Spot', expanded: true } }); - mocks.getPlacePhoto.mockResolvedValue({ photoUrl: 'http://x/y.jpg', attribution: 'CC' }); - mocks.reverseGeocode.mockResolvedValue({ name: 'Spot', address: 'Street 1' }); - mocks.resolveGoogleMapsUrl.mockResolvedValue({ lat: 52.5, lng: 13.4, name: null, address: null }); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('POST /search success', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'berlin' } })); - - it('POST /search missing query (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: {} })); - - it('POST /search service error', async () => { - mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 })); - mocks.searchPlaces.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 })); - await expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/search', body: { query: 'x' } }); - }); - - it('POST /autocomplete success', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'ber' } })); - - it('POST /autocomplete missing input (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: {} })); - - it('POST /autocomplete too long (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/autocomplete', body: { input: 'x'.repeat(201) } })); - - it('POST /autocomplete invalid locationBias (400)', () => - expectParity(expressServer, nestServer, { - method: 'post', path: '/api/maps/autocomplete', - body: { input: 'ber', locationBias: { low: { lat: 1, lng: 'no' }, high: { lat: 2, lng: 3 } } }, - })); - - it('GET /details/:placeId', () => - expectParity(expressServer, nestServer, { path: '/api/maps/details/p1' })); - - it('GET /details/:placeId?expand=full', () => - expectParity(expressServer, nestServer, { path: '/api/maps/details/p1', query: { expand: 'full' } })); - - it('GET /place-photo/:placeId', () => - expectParity(expressServer, nestServer, { path: '/api/maps/place-photo/p1', query: { lat: '1', lng: '2' } })); - - it('GET /reverse success', () => - expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5', lng: '13.4' } })); - - it('GET /reverse missing lat/lng (400)', () => - expectParity(expressServer, nestServer, { path: '/api/maps/reverse', query: { lat: '52.5' } })); - - it('POST /resolve-url success', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: { url: 'https://maps.app.goo.gl/x' } })); - - it('POST /resolve-url missing url (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/maps/resolve-url', body: {} })); -}); diff --git a/server/tests/parity/l4.parity.test.ts b/server/tests/parity/l4.parity.test.ts deleted file mode 100644 index 77b808a9..00000000 --- a/server/tests/parity/l4.parity.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * L4 parity — categories CRUD. - * - * Fires the same request at the legacy Express /api/categories route and the - * migrated Nest controller with categoryService mocked identically for both, - * asserting client-identical status + body. Auth + admin are neutralised the - * same way for both apps (a fixed admin user); the 401/403 paths are covered by - * the e2e test against the real guards. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { adminUser } = vi.hoisted(() => ({ - adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = adminUser; - next(); - }, - adminOnly: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(), - extractToken: () => 'token', - verifyJwtAndLoadUser: () => adminUser, -})); - -const { mocks } = vi.hoisted(() => ({ - mocks: { - listCategories: vi.fn(), - createCategory: vi.fn(), - getCategoryById: vi.fn(), - updateCategory: vi.fn(), - deleteCategory: vi.fn(), - }, -})); -vi.mock('../../src/services/categoryService', () => mocks); - -import categoriesRoutes from '../../src/routes/categories'; -import { CategoriesModule } from '../../src/nest/categories/categories.module'; -import { DatabaseModule } from '../../src/nest/database/database.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -const cat = { id: 1, name: 'Food', color: '#fff', icon: '🍔' }; - -describe('L4 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/categories', categoriesRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [DatabaseModule, CategoriesModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - mocks.listCategories.mockReturnValue([cat]); - mocks.createCategory.mockReturnValue(cat); - mocks.updateCategory.mockReturnValue({ ...cat, name: 'Drinks' }); - mocks.getCategoryById.mockImplementation((id: string | number) => (String(id) === '1' ? cat : undefined)); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/categories' })); - - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: { name: 'Food', color: '#fff', icon: '🍔' } })); - - it('POST / missing name (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/categories', body: {} })); - - it('PUT /:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/1', body: { name: 'Drinks' } })); - - it('PUT /:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/categories/9', body: { name: 'X' } })); - - it('DELETE /:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/1' })); - - it('DELETE /:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/categories/9' })); -}); diff --git a/server/tests/parity/l5.parity.test.ts b/server/tests/parity/l5.parity.test.ts deleted file mode 100644 index 499c70ef..00000000 --- a/server/tests/parity/l5.parity.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * L5 parity — tags CRUD. - * - * Fires the same request at the legacy Express /api/tags route and the migrated - * Nest controller with tagService mocked identically for both, asserting - * client-identical status + body. Auth is neutralised identically (a fixed user); - * the 401 path is covered by the e2e test against the real guard. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ - fixedUser: { id: 5, username: 'u', email: 'u@example.test', role: 'user' }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { mocks } = vi.hoisted(() => ({ - mocks: { - listTags: vi.fn(), - createTag: vi.fn(), - getTagByIdAndUser: vi.fn(), - updateTag: vi.fn(), - deleteTag: vi.fn(), - }, -})); -vi.mock('../../src/services/tagService', () => mocks); - -import tagsRoutes from '../../src/routes/tags'; -import { TagsModule } from '../../src/nest/tags/tags.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -const tag = { id: 1, user_id: 5, name: 'Beach', color: '#10b981' }; - -describe('L5 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/tags', tagsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [TagsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - mocks.listTags.mockReturnValue([tag]); - mocks.createTag.mockReturnValue(tag); - mocks.updateTag.mockReturnValue({ ...tag, name: 'Hike' }); - mocks.getTagByIdAndUser.mockImplementation((id: string | number) => (String(id) === '1' ? tag : undefined)); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /', () => expectParity(expressServer, nestServer, { path: '/api/tags' })); - - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: { name: 'Beach', color: '#10b981' } })); - - it('POST / missing name (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/tags', body: {} })); - - it('PUT /:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/1', body: { name: 'Hike' } })); - - it('PUT /:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/tags/9', body: { name: 'X' } })); - - it('DELETE /:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/1' })); - - it('DELETE /:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/tags/9' })); -}); diff --git a/server/tests/parity/l6.parity.test.ts b/server/tests/parity/l6.parity.test.ts deleted file mode 100644 index eb23224a..00000000 --- a/server/tests/parity/l6.parity.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** - * L6 parity — notifications. - * - * Fires the same request at the legacy Express /api/notifications route and the - * migrated Nest controller with the three notification services mocked - * identically for both, asserting client-identical status + body. Includes the - * route-ordering trap (DELETE /in-app/all must NOT be captured by /in-app/:id). - * Auth/admin are neutralised the same way (a fixed admin user); the 401/403 - * paths are covered by the e2e test against the real guard. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { adminUser } = vi.hoisted(() => ({ - adminUser: { id: 1, username: 'admin', email: 'admin@example.test', role: 'admin' }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = adminUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => adminUser, -})); - -const { prefs, inapp, channels } = vi.hoisted(() => ({ - prefs: { getPreferencesMatrix: vi.fn(), setPreferences: vi.fn() }, - inapp: { - getNotifications: vi.fn(), getUnreadCount: vi.fn(), markRead: vi.fn(), markUnread: vi.fn(), - markAllRead: vi.fn(), deleteNotification: vi.fn(), deleteAll: vi.fn(), respondToBoolean: vi.fn(), - }, - channels: { - testSmtp: vi.fn(), testWebhook: vi.fn(), testNtfy: vi.fn(), - getUserWebhookUrl: vi.fn(), getAdminWebhookUrl: vi.fn(), - getUserNtfyConfig: vi.fn(), getAdminNtfyConfig: vi.fn(), - }, -})); -vi.mock('../../src/services/notificationPreferencesService', () => prefs); -vi.mock('../../src/services/inAppNotifications', () => inapp); -vi.mock('../../src/services/notifications', () => channels); - -import notificationsRoutes from '../../src/routes/notifications'; -import { NotificationsModule } from '../../src/nest/notifications/notifications.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('L6 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/notifications', notificationsRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [NotificationsModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - prefs.getPreferencesMatrix.mockReturnValue({ preferences: {}, available_channels: {}, event_types: [], implemented_combos: {} }); - inapp.getNotifications.mockReturnValue({ notifications: [{ id: 1 }], total: 1, unread_count: 1 }); - inapp.getUnreadCount.mockReturnValue(2); - inapp.markAllRead.mockReturnValue(3); - inapp.deleteAll.mockReturnValue(4); - inapp.markRead.mockImplementation((id: number) => id === 5); - inapp.deleteNotification.mockImplementation((id: number) => id === 5); - inapp.respondToBoolean.mockResolvedValue({ success: true, notification: { id: 5, response: 'positive' } }); - channels.testSmtp.mockResolvedValue({ success: true }); - channels.testWebhook.mockResolvedValue({ success: true }); - channels.getAdminNtfyConfig.mockReturnValue({ server: null, token: null }); - channels.getUserNtfyConfig.mockReturnValue(null); - channels.testNtfy.mockResolvedValue({ success: true }); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /preferences', () => expectParity(expressServer, nestServer, { path: '/api/notifications/preferences' })); - - it('PUT /preferences', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/preferences', body: { trip_invite: { inapp: true } } })); - - it('POST /test-smtp (admin, 200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-smtp', body: { email: 'x@y.z' } })); - - it('POST /test-webhook with a url (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'https://hooks.example/x' } })); - - it('POST /test-webhook invalid url (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-webhook', body: { url: 'not a url' } })); - - it('POST /test-ntfy with a topic (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: { topic: 'mytopic' } })); - - it('POST /test-ntfy no topic (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/test-ntfy', body: {} })); - - it('GET /in-app', () => - expectParity(expressServer, nestServer, { path: '/api/notifications/in-app', query: { limit: '10', offset: '0' } })); - - it('GET /in-app/unread-count', () => - expectParity(expressServer, nestServer, { path: '/api/notifications/in-app/unread-count' })); - - it('PUT /in-app/read-all', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/read-all' })); - - it('DELETE /in-app/all (must not be captured by /:id)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/all' })); - - it('PUT /in-app/:id/read success', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/5/read' })); - - it('PUT /in-app/:id/read 404', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/9/read' })); - - it('PUT /in-app/:id/read invalid id (400)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/notifications/in-app/abc/read' })); - - it('DELETE /in-app/:id success', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/notifications/in-app/5' })); - - it('POST /in-app/:id/respond success (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'positive' } })); - - it('POST /in-app/:id/respond invalid value (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/notifications/in-app/5/respond', body: { response: 'maybe' } })); -}); diff --git a/server/tests/parity/l7.parity.test.ts b/server/tests/parity/l7.parity.test.ts deleted file mode 100644 index 2e69ad25..00000000 --- a/server/tests/parity/l7.parity.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * L7 parity — atlas addon. - * - * Fires the same request at the legacy Express /api/addons/atlas route and the - * migrated Nest controller with atlasService mocked identically for both, - * asserting client-identical status + body. (Cache-Control headers are asserted - * in the controller unit test; expectParity compares status + body.) Auth is - * neutralised identically; the 401 path is covered by the e2e test. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { mocks } = vi.hoisted(() => ({ - mocks: { - getStats: vi.fn(), - getCountryPlaces: vi.fn(), - markCountryVisited: vi.fn(), - unmarkCountryVisited: vi.fn(), - markRegionVisited: vi.fn(), - unmarkRegionVisited: vi.fn(), - getVisitedRegions: vi.fn(), - getRegionGeo: vi.fn(), - listBucketList: vi.fn(), - createBucketItem: vi.fn(), - updateBucketItem: vi.fn(), - deleteBucketItem: vi.fn(), - }, -})); -vi.mock('../../src/services/atlasService', () => mocks); - -import atlasRoutes from '../../src/routes/atlas'; -import { AtlasModule } from '../../src/nest/atlas/atlas.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('L7 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/addons/atlas', atlasRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [AtlasModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - mocks.getStats.mockResolvedValue({ countries: 3, cities: 10, continents: 2 }); - mocks.getVisitedRegions.mockResolvedValue({ regions: {} }); - mocks.getRegionGeo.mockResolvedValue({ type: 'FeatureCollection', features: [{ id: 1 }] }); - mocks.getCountryPlaces.mockReturnValue({ places: [] }); - mocks.listBucketList.mockReturnValue([{ id: 1, name: 'Tokyo' }]); - mocks.createBucketItem.mockReturnValue({ id: 2, name: 'Kyoto' }); - mocks.updateBucketItem.mockImplementation((_u: number, id: string | number) => (String(id) === '1' ? { id: 1, name: 'Edited' } : null)); - mocks.deleteBucketItem.mockImplementation((_u: number, id: string | number) => String(id) === '1'); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /stats', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/stats' })); - it('GET /regions', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions' })); - it('GET /regions/geo empty', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo' })); - it('GET /regions/geo non-empty', () => - expectParity(expressServer, nestServer, { path: '/api/addons/atlas/regions/geo', query: { countries: 'DE,FR' } })); - it('GET /country/:code', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/country/de' })); - it('POST /country/:code/mark (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/country/de/mark' })); - it('DELETE /country/:code/mark', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/country/de/mark' })); - it('POST /region/:code/mark (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria', country_code: 'de' } })); - it('POST /region/:code/mark missing fields (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/region/by/mark', body: { name: 'Bavaria' } })); - it('DELETE /region/:code/mark', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/region/by/mark' })); - it('GET /bucket-list', () => expectParity(expressServer, nestServer, { path: '/api/addons/atlas/bucket-list' })); - it('POST /bucket-list create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: 'Kyoto' } })); - it('POST /bucket-list blank name (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/atlas/bucket-list', body: { name: ' ' } })); - it('PUT /bucket-list/:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/1', body: { name: 'Edited' } })); - it('PUT /bucket-list/:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/atlas/bucket-list/9', body: { name: 'X' } })); - it('DELETE /bucket-list/:id found (200)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/1' })); - it('DELETE /bucket-list/:id not found (404)', () => - expectParity(expressServer, nestServer, { method: 'delete', path: '/api/addons/atlas/bucket-list/9' })); -}); diff --git a/server/tests/parity/l8-vacay.parity.test.ts b/server/tests/parity/l8-vacay.parity.test.ts deleted file mode 100644 index fa180f9e..00000000 --- a/server/tests/parity/l8-vacay.parity.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * S1 parity — vacay addon. - * - * Fires the same request at the legacy Express /api/addons/vacay route and the - * migrated Nest controller with vacayService mocked identically for both, - * asserting client-identical status + body. Auth is neutralised identically; the - * 401 path is covered by the e2e test. Covers the validation/403/error-status - * paths and the POST-stays-200 behaviour. - */ -import { describe, it, beforeAll, afterAll, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -const { svc } = vi.hoisted(() => ({ - svc: { - getPlanData: vi.fn(), getActivePlanId: vi.fn(), getActivePlan: vi.fn(), updatePlan: vi.fn(), - addHolidayCalendar: vi.fn(), updateHolidayCalendar: vi.fn(), deleteHolidayCalendar: vi.fn(), - getPlanUsers: vi.fn(), setUserColor: vi.fn(), sendInvite: vi.fn(), acceptInvite: vi.fn(), - declineInvite: vi.fn(), cancelInvite: vi.fn(), dissolvePlan: vi.fn(), getAvailableUsers: vi.fn(), - listYears: vi.fn(), addYear: vi.fn(), deleteYear: vi.fn(), getEntries: vi.fn(), - toggleEntry: vi.fn(), toggleCompanyHoliday: vi.fn(), getStats: vi.fn(), updateStats: vi.fn(), - getCountries: vi.fn(), getHolidays: vi.fn(), - }, -})); -vi.mock('../../src/services/vacayService', () => svc); - -import vacayRoutes from '../../src/routes/vacay'; -import { VacayModule } from '../../src/nest/vacay/vacay.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S1 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/addons/vacay', vacayRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [VacayModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - svc.getActivePlanId.mockReturnValue(10); - svc.getActivePlan.mockReturnValue({ id: 10 }); - svc.getPlanUsers.mockReturnValue([{ id: 1 }]); - svc.getPlanData.mockReturnValue({ plan: { id: 10 }, users: [] }); - svc.addHolidayCalendar.mockReturnValue({ id: 1, region: 'DE-BY' }); - svc.listYears.mockReturnValue([2026]); - svc.addYear.mockReturnValue([2026, 2027]); - svc.getEntries.mockReturnValue({ entries: [] }); - svc.toggleEntry.mockReturnValue({ action: 'added' }); - svc.getStats.mockReturnValue({ used: 5 }); - svc.getAvailableUsers.mockReturnValue([{ id: 2 }]); - svc.sendInvite.mockReturnValue({}); - svc.getCountries.mockResolvedValue({ data: [{ code: 'DE' }] }); - svc.getHolidays.mockResolvedValue({ data: [{ date: '2026-01-01' }] }); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET /plan', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/plan' })); - it('POST /plan/holiday-calendars (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: { region: 'DE-BY', label: 'Bayern' } })); - it('POST /plan/holiday-calendars missing region (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/plan/holiday-calendars', body: {} })); - it('PUT /color in-plan (200)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff' } })); - it('PUT /color not in plan (403)', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/addons/vacay/color', body: { color: '#fff', target_user_id: 99 } })); - it('POST /invite (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: { user_id: 2 } })); - it('POST /invite missing user_id (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/invite', body: {} })); - it('POST /dissolve (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/dissolve' })); - it('GET /available-users', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/available-users' })); - it('GET /years', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/years' })); - it('POST /years (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: { year: 2027 } })); - it('POST /years missing (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/years', body: {} })); - it('GET /entries/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/entries/2026' })); - it('POST /entries/toggle (200)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: { date: '2026-07-01' } })); - it('POST /entries/toggle missing date (400)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/addons/vacay/entries/toggle', body: {} })); - it('GET /stats/:year', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/stats/2026' })); - it('GET /holidays/countries', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/countries' })); - it('GET /holidays/:year/:country', () => expectParity(expressServer, nestServer, { path: '/api/addons/vacay/holidays/2026/DE' })); -}); diff --git a/server/tests/parity/l9-packing.parity.test.ts b/server/tests/parity/l9-packing.parity.test.ts deleted file mode 100644 index ca899390..00000000 --- a/server/tests/parity/l9-packing.parity.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * S2 parity — packing (trip-scoped). - * - * Fires the same request at the legacy Express /api/trips/:tripId/packing route - * (mounted with mergeParams) and the migrated Nest controller, with - * packingService, the permission check, the WebSocket broadcast and auth all - * mocked identically for both. Asserts client-identical status + body, including - * the trip-access 404, the permission 403, and POST /apply-template staying 200. - */ -import { describe, it, beforeAll, afterAll, beforeEach, vi } from 'vitest'; -import express from 'express'; -import type { Server } from 'http'; -import { Test } from '@nestjs/testing'; -import { expectParity } from './parity'; - -const { fixedUser, trip } = vi.hoisted(() => ({ - fixedUser: { id: 1, username: 'u', email: 'u@example.test', role: 'user' }, - trip: { id: 5, user_id: 1 }, -})); - -vi.mock('../../src/db/database', () => ({ - db: { prepare: () => ({ get: () => undefined, all: () => [], run: () => undefined }) }, - closeDb: () => {}, - reinitialize: () => {}, -})); - -vi.mock('../../src/middleware/auth', () => ({ - authenticate: (req: express.Request, _res: express.Response, next: express.NextFunction) => { - (req as express.Request & { user: unknown }).user = fixedUser; - next(); - }, - extractToken: () => 'token', - verifyJwtAndLoadUser: () => fixedUser, -})); - -vi.mock('../../src/websocket', () => ({ broadcast: vi.fn() })); - -const { checkPermission } = vi.hoisted(() => ({ checkPermission: vi.fn() })); -vi.mock('../../src/services/permissions', () => ({ checkPermission })); - -const { svc } = vi.hoisted(() => ({ - svc: { - verifyTripAccess: vi.fn(), listItems: vi.fn(), createItem: vi.fn(), updateItem: vi.fn(), - deleteItem: vi.fn(), bulkImport: vi.fn(), reorderItems: vi.fn(), listBags: vi.fn(), - createBag: vi.fn(), updateBag: vi.fn(), deleteBag: vi.fn(), applyTemplate: vi.fn(), - saveAsTemplate: vi.fn(), setBagMembers: vi.fn(), getCategoryAssignees: vi.fn(), - updateCategoryAssignees: vi.fn(), reorderBags: vi.fn(), - }, -})); -vi.mock('../../src/services/packingService', () => svc); - -import packingRoutes from '../../src/routes/packing'; -import { PackingModule } from '../../src/nest/packing/packing.module'; -import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; - -describe('S2 parity (Express vs Nest)', () => { - let expressServer: express.Express; - let nestServer: Server; - let nestApp: Awaited>; - - function buildExpress() { - const app = express(); - app.use(express.json()); - app.use('/api/trips/:tripId/packing', packingRoutes); - return app; - } - - async function buildNest() { - const moduleRef = await Test.createTestingModule({ imports: [PackingModule] }).compile(); - const nest = moduleRef.createNestApplication(); - nest.useGlobalFilters(new TrekExceptionFilter()); - await nest.init(); - return nest; - } - - beforeAll(async () => { - expressServer = buildExpress(); - nestApp = await buildNest(); - nestServer = nestApp.getHttpServer(); - svc.listItems.mockReturnValue([{ id: 1, name: 'Socks' }]); - svc.createItem.mockReturnValue({ id: 9, name: 'Socks' }); - svc.bulkImport.mockReturnValue([{ id: 1 }]); - svc.updateItem.mockImplementation((_t: string, id: string) => (id === '9' ? { id: 9 } : null)); - svc.listBags.mockReturnValue([{ id: 1 }]); - svc.createBag.mockReturnValue({ id: 2 }); - svc.applyTemplate.mockReturnValue([{ id: 1 }]); - svc.getCategoryAssignees.mockReturnValue([]); - }); - - beforeEach(() => { - svc.verifyTripAccess.mockReturnValue(trip); - checkPermission.mockReturnValue(true); - }); - - afterAll(async () => { - await nestApp.close(); - }); - - it('GET / list', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' })); - - it('GET / 404 when trip not accessible', () => { - svc.verifyTripAccess.mockReturnValue(undefined); - return expectParity(expressServer, nestServer, { path: '/api/trips/5/packing' }); - }); - - it('POST / create (201)', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } })); - - it('POST / 403 without permission', () => { - checkPermission.mockReturnValue(false); - return expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: { name: 'Socks' } }); - }); - - it('POST / 400 missing name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing', body: {} })); - - it('POST /import 400 empty', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/import', body: { items: [] } })); - - it('PUT /reorder', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/reorder', body: { orderedIds: [1, 2] } })); - - it('PUT /:id 404 when item missing', () => - expectParity(expressServer, nestServer, { method: 'put', path: '/api/trips/5/packing/77', body: { name: 'X' } })); - - it('GET /bags', () => expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/bags' })); - - it('POST /bags 400 blank name', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/bags', body: { name: ' ' } })); - - it('POST /apply-template/:id stays 200', () => - expectParity(expressServer, nestServer, { method: 'post', path: '/api/trips/5/packing/apply-template/t1' })); - - it('GET /category-assignees', () => - expectParity(expressServer, nestServer, { path: '/api/trips/5/packing/category-assignees' })); -}); diff --git a/server/tests/parity/parity.ts b/server/tests/parity/parity.ts deleted file mode 100644 index 47275b54..00000000 --- a/server/tests/parity/parity.ts +++ /dev/null @@ -1,42 +0,0 @@ -import request from 'supertest'; -import { expect } from 'vitest'; -import type { Server } from 'http'; - -export interface ParityRequest { - method?: 'get' | 'post' | 'put' | 'patch' | 'delete'; - path: string; - query?: Record; - body?: unknown; - /** Request headers (e.g. a Cookie/Authorization) applied to BOTH stacks. */ - headers?: Record; -} - -/** - * Reusable Nest-vs-Express parity harness. - * - * Fires the same HTTP request at the legacy Express app and the migrated Nest app - * and asserts the response is client-identical — same status code and same JSON - * body. With the underlying service mocked identically for both, any difference is - * purely framework-layer (routing, validation, error envelope), which is exactly - * what a migration must not change. Use one assertion per migrated route/case. - */ -export async function expectParity( - expressServer: Server | Express.Application, - nestServer: Server, - req: ParityRequest, -): Promise { - const fire = (target: Server | Express.Application) => { - const method = req.method ?? 'get'; - let r = request(target as never)[method](req.path); - if (req.headers) for (const [k, v] of Object.entries(req.headers)) r = r.set(k, v); - if (req.query) r = r.query(req.query); - if (req.body !== undefined) r = r.send(req.body as object); - return r; - }; - - const [ex, ne] = await Promise.all([fire(expressServer), fire(nestServer)]); - - const label = `${(req.method ?? 'GET').toUpperCase()} ${req.path}`; - expect(ne.status, `${label}: status mismatch`).toBe(ex.status); - expect(ne.body, `${label}: body mismatch`).toEqual(ex.body); -} diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 9d831cff..3da8eacb 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -71,8 +71,10 @@ beforeEach(() => { isAddonEnabledMock.mockReturnValue(true); // Default mock: returns a trip-summary-shaped value from the real in-memory DB - // so that the trip title / existence match what tests insert, but budget/packing - // are arrays (as prompts.ts expects), not the object shape getTripSummary now returns. + // so the trip title / existence match what tests insert. `budget` mirrors the + // real getTripSummary object shape ({ items, total, ... }) that prompts.ts reads + // via budget.items/budget.total; packing stays an array (the packing prompt + // tolerates it). mockGetTripSummary.mockImplementation((tripId: any) => { const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any; if (!trip) return null; @@ -87,8 +89,13 @@ beforeEach(() => { trip, days: [], members, - budget: budgetRows, // array shape expected by prompts.ts - packing: packingRows, // array shape expected by prompts.ts + budget: { + items: budgetRows, + item_count: budgetRows.length, + total: budgetRows.reduce((sum, i) => sum + (i.total_price || 0), 0), + currency: trip.currency, + }, + packing: packingRows, // array shape; packing prompt tolerates it reservations: [], collabNotes: [], }; diff --git a/server/tests/unit/nest/strangler.test.ts b/server/tests/unit/nest/strangler.test.ts deleted file mode 100644 index ade88dd1..00000000 --- a/server/tests/unit/nest/strangler.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, it, expect, afterEach } from 'vitest'; -import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler'; - -describe('strangler toggle', () => { - const original = process.env.NEST_PREFIXES; - afterEach(() => { - if (original === undefined) delete process.env.NEST_PREFIXES; - else process.env.NEST_PREFIXES = original; - }); - - it('defaults to the migrated prefixes when NEST_PREFIXES is unset', () => { - delete process.env.NEST_PREFIXES; - expect(getNestPrefixes()).toEqual([ - '/api/_nest', - '/api/weather', - '/api/airports', - '/api/config', - '/api/system-notices', - '/api/maps', - '/api/categories', - '/api/tags', - '/api/notifications', - '/api/addons/atlas', - '/api/addons/vacay', - '/api/trips/:tripId/packing', - '/api/trips/:tripId/todo', - '/api/trips/:tripId/budget', - '/api/trips/:tripId/reservations', - '/api/trips/:tripId/accommodations', - '/api/trips/:tripId/days', - '/api/trips/:tripId/assignments', - '/api/trips/:tripId/places', - '/api/trips/:tripId/collab', - '/api/trips/:tripId/files', - '/api/photos', - '/api/journeys', - '/api/public/journey', - '/api/shared', - '/api/settings', - '/api/backup', - '/api/auth/app-config', - '/api/auth/demo-login', - '/api/auth/invite', - '/api/auth/register', - '/api/auth/login', - '/api/auth/forgot-password', - '/api/auth/reset-password', - '/api/auth/me', - '/api/auth/logout', - '/api/auth/avatar', - '/api/auth/users', - '/api/auth/validate-keys', - '/api/auth/app-settings', - '/api/auth/travel-stats', - '/api/auth/mfa', - '/api/auth/mcp-tokens', - '/api/auth/ws-token', - '/api/auth/resource-token', - '/api/auth/oidc', - '/api/oauth', - '/oauth/token', - '/oauth/userinfo', - '/oauth/revoke', - '/api/admin', - '/api/trips/:tripId/share-link', - '/api/trips|', - '/api/trips/:tripId|', - '/api/trips/:tripId/members', - '/api/trips/:tripId/cover', - '/api/trips/:tripId/copy', - '/api/trips/:tripId/bundle', - '/api/trips/:tripId/export.ics', - ]); - }); - - it('parses NEST_PREFIXES (comma-separated, trimmed)', () => { - process.env.NEST_PREFIXES = '/api/weather, /api/airports'; - expect(getNestPrefixes()).toEqual(['/api/weather', '/api/airports']); - }); - - it('treats an empty NEST_PREFIXES as "all routes on legacy"', () => { - process.env.NEST_PREFIXES = ''; - expect(getNestPrefixes()).toEqual([]); - }); - - it('matches exact prefixes and subpaths but not lookalikes', () => { - const match = makeNestPathMatcher(['/api/_nest']); - expect(match('/api/_nest')).toBe(true); - expect(match('/api/_nest/health')).toBe(true); - expect(match('/api/_nestxyz')).toBe(false); - expect(match('/api/health')).toBe(false); - }); - - it('exact prefixes (trailing |) match the path only, not sub-paths', () => { - const match = makeNestPathMatcher(['/api/trips|', '/api/trips/:tripId|', '/api/trips/:tripId/members']); - expect(match('/api/trips')).toBe(true); - expect(match('/api/trips/5')).toBe(true); - expect(match('/api/trips/5/members')).toBe(true); - expect(match('/api/trips/5/members/2')).toBe(true); - // Not-yet-migrated nested mounts stay on Express: - expect(match('/api/trips/5/collab')).toBe(false); - expect(match('/api/trips/5/files')).toBe(false); - expect(match('/api/trips/5/cover')).toBe(false); - }); - - it('routes auth sub-paths via their own explicit prefixes (no broad /api/auth catch-all)', () => { - // The account prefixes alone must NOT swallow the separately-mounted oidc flow: - const accountOnly = makeNestPathMatcher(['/api/auth/login', '/api/auth/me', '/api/auth/mfa', '/api/auth/mcp-tokens']); - expect(accountOnly('/api/auth/login')).toBe(true); - expect(accountOnly('/api/auth/me/password')).toBe(true); - expect(accountOnly('/api/auth/mfa/verify-login')).toBe(true); - expect(accountOnly('/api/auth/mcp-tokens/abc')).toBe(true); - expect(accountOnly('/api/auth/oidc')).toBe(false); - expect(accountOnly('/api/auth/oidc/callback')).toBe(false); - // oidc is matched only by its own prefix (A2): - const withOidc = makeNestPathMatcher(['/api/auth/oidc']); - expect(withOidc('/api/auth/oidc/login')).toBe(true); - expect(withOidc('/api/auth/oidc/callback')).toBe(true); - }); - - it('routes the OAuth public endpoints to Nest but leaves the SDK mounts on Express (A3)', () => { - const match = makeNestPathMatcher(['/oauth/token', '/oauth/userinfo', '/oauth/revoke', '/api/oauth']); - expect(match('/oauth/token')).toBe(true); - expect(match('/oauth/userinfo')).toBe(true); - expect(match('/oauth/revoke')).toBe(true); - expect(match('/api/oauth/clients')).toBe(true); - expect(match('/api/oauth/authorize/validate')).toBe(true); - // The MCP SDK handlers must stay on Express: - expect(match('/oauth/authorize')).toBe(false); - expect(match('/oauth/register')).toBe(false); - expect(match('/oauth/consent')).toBe(false); - }); - - it('matches a pattern prefix with :param without capturing sibling routes', () => { - const match = makeNestPathMatcher(['/api/trips/:tripId/packing']); - expect(match('/api/trips/5/packing')).toBe(true); - expect(match('/api/trips/5/packing/bags')).toBe(true); - expect(match('/api/trips/abc/packing/123')).toBe(true); - // Sibling trip routes stay on Express: - expect(match('/api/trips/5/days')).toBe(false); - expect(match('/api/trips/5/places')).toBe(false); - expect(match('/api/trips/5')).toBe(false); - expect(match('/api/trips/5/packingx')).toBe(false); - }); -}); diff --git a/server/tests/unit/services/journeyService.test.ts b/server/tests/unit/services/journeyService.test.ts index 82b14d55..09aa7fe6 100644 --- a/server/tests/unit/services/journeyService.test.ts +++ b/server/tests/unit/services/journeyService.test.ts @@ -17,7 +17,15 @@ const { testDb, dbMock } = vi.hoisted(() => { closeDb: () => {}, reinitialize: () => {}, getPlaceWithTags: () => null, - canAccessTrip: () => null, + // Mirror the real canAccessTrip semantics against the test DB (owner or member + // → truthy access row, else undefined) so addTripToJourney's trip-access guard + // behaves as in production. (Was an unused `() => null` stub before the guard existed.) + canAccessTrip: (tripId: number | string, userId: number) => + db + .prepare( + 'SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)', + ) + .get(userId, tripId, userId), isOwner: () => false, }; return { testDb: db, dbMock: mock }; @@ -417,6 +425,22 @@ describe('addTripToJourney / removeTripFromJourney', () => { expect(link).toBeDefined(); }); + it('JOURNEY-SVC-024b: refuses to link a trip the caller cannot access (IDOR guard)', () => { + const { user } = createUser(testDb); + const { user: stranger } = createUser(testDb); + const journey = createJourney(testDb, user.id); + // A trip owned by someone else, that `user` is not a member of. + const foreignTrip = createTrip(testDb, stranger.id, { title: "Stranger's Trip" }); + + const result = addTripToJourney(journey.id, foreignTrip.id, user.id); + + expect(result).toBe(false); + const link = testDb.prepare( + 'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?' + ).get(journey.id, foreignTrip.id); + expect(link).toBeUndefined(); + }); + it('JOURNEY-SVC-025: syncs places as skeleton entries when linking a trip', () => { const { user } = createUser(testDb); const journey = createJourney(testDb, user.id); diff --git a/server/tests/websocket/connection.test.ts b/server/tests/websocket/connection.test.ts index 4d5b6f76..24b60910 100644 --- a/server/tests/websocket/connection.test.ts +++ b/server/tests/websocket/connection.test.ts @@ -39,27 +39,31 @@ vi.mock('../../src/config', () => ({ JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', updateJwtSecret: () => {}, + DEFAULT_LANGUAGE: 'en', })); -import { createApp } from '../../src/app'; +import type { INestApplication } from '@nestjs/common'; +import { buildApp } from '../../src/bootstrap'; import { createTables } from '../../src/db/schema'; import { runMigrations } from '../../src/db/migrations'; -import { resetTestDb } from '../helpers/test-db'; +import { resetTestDb, resetRateLimits } from '../helpers/test-db'; import { createUser, createTrip } from '../helpers/factories'; import { authCookie } from '../helpers/auth'; -import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; import { setupWebSocket } from '../../src/websocket'; import { createEphemeralToken } from '../../src/services/ephemeralTokens'; let server: http.Server; let wsUrl: string; +let nestApp: INestApplication; beforeAll(async () => { createTables(testDb); runMigrations(testDb); - const app = createApp(); - server = http.createServer(app); + // Real WebSocket against the unified NestJS app (Express is gone). buildApp owns + // the same composition production uses; we attach the real ws server to it. + nestApp = await buildApp(); + server = http.createServer(nestApp.getHttpAdapter().getInstance()); setupWebSocket(server); await new Promise(resolve => server.listen(0, resolve)); @@ -71,13 +75,13 @@ afterAll(async () => { await new Promise((resolve, reject) => server.close(err => err ? reject(err) : resolve()) ); + await nestApp.close(); testDb.close(); }); beforeEach(() => { resetTestDb(testDb); - loginAttempts.clear(); - mfaAttempts.clear(); + resetRateLimits(nestApp); }); /** Buffered WebSocket wrapper that never drops messages. */