diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts new file mode 100644 index 000000000..a5ff0cf08 --- /dev/null +++ b/client/src/api/client.behavior.test.ts @@ -0,0 +1,462 @@ +// FE-APIWIRE-001 to FE-APIWIRE-035 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { AxiosError, type AxiosAdapter, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { weatherResultSchema } from '@trek/shared' + +// client.ts probes the health endpoint to tell an edge-proxy auth wall apart +// from a plain offline boot β€” the probe result decides whether it tears down +// the service worker, so the tests drive it directly. +const { probeNow } = vi.hoisted(() => ({ + probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'), +})) +vi.mock('../sync/connectivity', () => ({ probeNow })) + +const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client') + +interface FakeLocation { + href: string + origin: string + pathname: string + search: string + hash: string + reload: () => void +} + +let reload: ReturnType void>> + +function setLocation(pathname: string, search = '', hash = ''): FakeLocation { + reload = vi.fn<() => void>() + const loc: FakeLocation = { + href: `http://localhost:3000${pathname}${search}${hash}`, + origin: 'http://localhost:3000', + pathname, + search, + hash, + reload, + } + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: loc }) + return loc +} + +const realLocation = window.location + +/** Records the outgoing config and answers 200 without touching the network. */ +function okAdapter(sink: InternalAxiosRequestConfig[]): AxiosAdapter { + return (config) => { + sink.push(config) + return Promise.resolve({ + data: { ok: true }, status: 200, statusText: 'OK', headers: {}, config, + } as AxiosResponse) + } +} + +/** Rejects the way a CORS/offline failure does: an error with no `response`. */ +const networkErrorAdapter: AxiosAdapter = (config) => + Promise.reject(new AxiosError('Network Error', AxiosError.ERR_NETWORK, config)) + +async function captureError(run: () => Promise): Promise { + const err = await run().then(() => null, (e: unknown) => e as AxiosError) + expect(err, 'expected the request to reject').not.toBeNull() + return err as AxiosError +} + +beforeEach(() => { + probeNow.mockResolvedValue('offline') + setLocation('/dashboard') +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation }) + delete (navigator as { serviceWorker?: unknown }).serviceWorker +}) + +describe('client > request interceptor', () => { + it('FE-APIWIRE-001: mutating requests get an idempotency key, reads do not', async () => { + const sink: InternalAxiosRequestConfig[] = [] + const adapter = okAdapter(sink) + + await apiClient.get('/probe', { adapter }) + await apiClient.post('/probe', {}, { adapter }) + await apiClient.put('/probe', {}, { adapter }) + await apiClient.patch('/probe', {}, { adapter }) + await apiClient.delete('/probe', { adapter }) + + const keys = sink.map(c => c.headers['X-Idempotency-Key']) + expect(keys[0]).toBeUndefined() + for (const key of keys.slice(1)) expect(typeof key).toBe('string') + }) + + it('FE-APIWIRE-002: each write gets its own key so retries can be deduplicated', async () => { + const sink: InternalAxiosRequestConfig[] = [] + const adapter = okAdapter(sink) + + await apiClient.post('/probe', {}, { adapter }) + await apiClient.post('/probe', {}, { adapter }) + + expect(sink[0].headers['X-Idempotency-Key']).not.toBe(sink[1].headers['X-Idempotency-Key']) + }) + + it('FE-APIWIRE-003: a pre-generated key from the mutation queue is left alone', async () => { + const sink: InternalAxiosRequestConfig[] = [] + + await apiClient.post('/probe', {}, { + adapter: okAdapter(sink), + headers: { 'X-Idempotency-Key': 'queued-key' }, + }) + + expect(sink[0].headers['X-Idempotency-Key']).toBe('queued-key') + }) + + it('FE-APIWIRE-004: falls back to a random token when crypto.randomUUID is missing', async () => { + const realCrypto = globalThis.crypto + vi.stubGlobal('crypto', { + getRandomValues: realCrypto.getRandomValues.bind(realCrypto), + } as unknown as Crypto) + + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.post('/probe', {}, { adapter: okAdapter(sink) }) + + const key = String(sink[0].headers['X-Idempotency-Key']) + expect(key).toMatch(/^[a-z0-9]+$/) + expect(key).not.toMatch(/-/) + }) + + it('FE-APIWIRE-005: the socket id header is omitted while no socket is connected', async () => { + const sink: InternalAxiosRequestConfig[] = [] + await apiClient.get('/probe', { adapter: okAdapter(sink) }) + expect(sink[0].headers['X-Socket-Id']).toBeUndefined() + }) + + it('FE-APIWIRE-034: a rejection from an earlier request interceptor is passed on untouched', async () => { + const boom = new Error('interceptor refused the request') + const id = apiClient.interceptors.request.use(() => Promise.reject(boom)) + const sink: InternalAxiosRequestConfig[] = [] + + try { + await expect(apiClient.post('/probe', {}, { adapter: okAdapter(sink) })).rejects.toBe(boom) + } finally { + apiClient.interceptors.request.eject(id) + } + + expect(sink).toHaveLength(0) + }) +}) + +describe('client > rate-limit translation', () => { + beforeEach(() => { + server.use(http.get('/api/limited', () => HttpResponse.json({ error: 'Too Many Requests' }, { status: 429 }))) + }) + + it('FE-APIWIRE-006: a 429 is rewritten in the stored app language', async () => { + localStorage.setItem('app_language', 'de') + const err = await captureError(() => apiClient.get('/limited')) + + expect(err.message).toBe('Zu viele Versuche. Bitte versuchen Sie es spΓ€ter erneut.') + expect((err.response?.data as { error: string }).error) + .toBe('Zu viele Versuche. Bitte versuchen Sie es spΓ€ter erneut.') + }) + + it('FE-APIWIRE-007: an unsupported language falls back to English', async () => { + localStorage.setItem('app_language', 'kl') + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-008: no stored language falls back to English', async () => { + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-009: a blocked localStorage still yields the English message', async () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled') + }) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.message).toBe('Too many attempts. Please try again later.') + }) + + it('FE-APIWIRE-010: a non-object 429 body is replaced with the translated error object', async () => { + server.use(http.get('/api/limited', () => new HttpResponse('slow down', { status: 429 }))) + const err = await captureError(() => apiClient.get('/limited')) + expect(err.response?.data).toEqual({ error: 'Too many attempts. Please try again later.' }) + }) +}) + +describe('client > proxy auth challenges', () => { + function installServiceWorker(unregister: () => Promise) { + const getRegistration = vi.fn(async () => ({ unregister })) + Object.defineProperty(navigator, 'serviceWorker', { + writable: true, configurable: true, value: { getRegistration }, + }) + return getRegistration + } + + it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => { + const unregister = vi.fn(async () => true) + installServiceWorker(unregister) + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(unregister).toHaveBeenCalled() + expect(reload).toHaveBeenCalledTimes(1) + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1') + }) + + it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => { + installServiceWorker(vi.fn(async () => true)) + sessionStorage.setItem('proxy_reauth_attempted', '1') + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-013: an HTML 401 on a public path never reloads', async () => { + setLocation('/login') + server.use(http.get('/api/auth/me', () => + new HttpResponse('login', { status: 401, headers: { 'Content-Type': 'text/html' } }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-014: a response-less failure that probes proxy-wall reloads', async () => { + probeNow.mockResolvedValue('proxy-wall') + installServiceWorker(vi.fn(async () => true)) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(probeNow).toHaveBeenCalled() + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-APIWIRE-015: a response-less failure that probes offline keeps the SW (#1346)', async () => { + probeNow.mockResolvedValue('offline') + const getRegistration = installServiceWorker(vi.fn(async () => true)) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(getRegistration).not.toHaveBeenCalled() + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-016: a failing unregister still reloads into the proxy challenge', async () => { + probeNow.mockResolvedValue('proxy-wall') + Object.defineProperty(navigator, 'serviceWorker', { + writable: true, configurable: true, + value: { getRegistration: vi.fn(async () => { throw new Error('SW gone') }) }, + }) + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-APIWIRE-017: a proxy-wall probe on a shared page does not reload', async () => { + setLocation('/shared/tok123') + probeNow.mockResolvedValue('proxy-wall') + + await captureError(() => apiClient.get('/auth/me', { adapter: networkErrorAdapter })) + + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-035: a 401 without a content-type is not mistaken for a proxy login page', async () => { + installServiceWorker(vi.fn(async () => true)) + server.use(http.get('/api/auth/me', () => new HttpResponse(null, { status: 401 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(reload).not.toHaveBeenCalled() + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) + + it('FE-APIWIRE-018: a successful response clears the reauth marker', async () => { + sessionStorage.setItem('proxy_reauth_attempted', '1') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ ok: true }))) + + await apiClient.get('/auth/me') + + expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull() + }) +}) + +describe('client > redirect handling', () => { + it('FE-APIWIRE-019: a JSON AUTH_REQUIRED 401 redirects with the full current path', async () => { + const loc = setLocation('/trips/7', '?tab=map', '#day-2') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2')) + }) + + it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => { + const loc = setLocation('/dashboard') + server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 }))) + + await captureError(() => apiClient.get('/auth/me')) + + expect(loc.href).toBe('/settings?mfa=required') + }) +}) + +describe('client > dev-only contract drift checks', () => { + it('FE-APIWIRE-021: parseInDev passes a matching payload straight through', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const payload = { temp: 21, main: 'Clear', description: 'clear sky', type: 'sun' } + + expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload) + expect(warn).not.toHaveBeenCalled() + }) + + it('FE-APIWIRE-022: parseInDev warns but still returns a drifting payload', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const payload = { temp: 'warm', main: 'Clear', description: 'clear sky', type: 'sun' } + + expect(parseInDev(weatherResultSchema, payload, 'weather.get')).toBe(payload) + expect(warn).toHaveBeenCalledWith( + '[api] weather.get: response did not match the @trek/shared schema', + expect.anything(), + ) + }) + + it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true }))) + + await expect(mapsApi.search('Rome')).resolves.toEqual({ nonsense: true }) + expect(warn).toHaveBeenCalledWith( + '[api] maps.search: response did not match the @trek/shared schema', + expect.anything(), + ) + }) +}) + +describe('client > pluginsApi.invoke namespace guard', () => { + it('FE-APIWIRE-024: a relative sub-path stays inside the plugin namespace', async () => { + let seen = '' + server.use(http.get('/api/plugins/koffi/ping', ({ request }) => { + seen = new URL(request.url).pathname + return HttpResponse.json({ pong: true }) + })) + + await expect(pluginsApi.invoke('koffi', '/ping')).resolves.toEqual({ pong: true }) + expect(seen).toBe('/api/plugins/koffi/ping') + }) + + it('FE-APIWIRE-025: method, body and query string survive the rewrite', async () => { + let received: unknown + let query = '' + server.use(http.post('/api/plugins/koffi/sync', async ({ request }) => { + received = await request.json() + query = new URL(request.url).search + return HttpResponse.json({ ok: true }) + })) + + await pluginsApi.invoke('koffi', 'sync?full=1', { method: 'POST', body: { since: 5 } }) + + expect(received).toEqual({ since: 5 }) + expect(query).toBe('?full=1') + }) + + it('FE-APIWIRE-026: traversal out of the plugin prefix is refused', async () => { + await expect(pluginsApi.invoke('koffi', '/../../auth/me')) + .rejects.toThrow('plugin route escapes its namespace') + }) + + it('FE-APIWIRE-027: an absolute off-origin target is refused', async () => { + await expect(pluginsApi.invoke('koffi', 'https://evil.test/steal')) + .rejects.toThrow('plugin route escapes its namespace') + }) + + it('FE-APIWIRE-028: an unparseable sub-path is refused before any request', async () => { + await expect(pluginsApi.invoke('koffi', 'http://')).rejects.toThrow('invalid plugin route') + }) +}) + +describe('client > adminApi.llmLocalPull', () => { + function streamingResponse(chunks: string[]): Response { + let i = 0 + const encoder = new TextEncoder() + return { + ok: true, + status: 200, + body: { + getReader: () => ({ + read: async () => (i < chunks.length + ? { done: false, value: encoder.encode(chunks[i++]) } + : { done: true, value: undefined }), + }), + }, + } as unknown as Response + } + + it('FE-APIWIRE-029: NDJSON progress lines are reported even when split across chunks', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([ + '{"status":"pulling","total":100,"completed":10}\n{"status":"pul', + 'ling","total":100,"completed":90}\n{"status":"success"}\n', + ])) + const onProgress = vi.fn((_p: { status?: string }) => {}) + + await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress) + + expect(onProgress.mock.calls.map(c => c[0])).toEqual([ + { status: 'pulling', total: 100, completed: 10 }, + { status: 'pulling', total: 100, completed: 90 }, + { status: 'success' }, + ]) + }) + + it('FE-APIWIRE-030: blank and half-written lines are skipped instead of throwing', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(streamingResponse([ + '\n \n{"status":"a"}\nnot-json\n{"status":"b"}\n', + ])) + const onProgress = vi.fn((_p: { status?: string }) => {}) + + await adminApi.llmLocalPull('http://ollama:11434', 'qwen3:8b', onProgress) + + expect(onProgress.mock.calls.map(c => c[0])).toEqual([{ status: 'a' }, { status: 'b' }]) + }) + + it('FE-APIWIRE-031: a JSON error body becomes the thrown message', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, status: 502, body: null, + json: async () => ({ error: 'ollama unreachable' }), + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('ollama unreachable') + }) + + it('FE-APIWIRE-032: a non-JSON error body falls back to the status code', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: false, status: 500, body: null, + json: async () => { throw new SyntaxError('not json') }, + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('Pull failed (500)') + }) + + it('FE-APIWIRE-033: a 200 without a readable body is treated as a failure', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ + ok: true, status: 200, body: null, + json: async () => ({}), + } as unknown as Response) + + await expect(adminApi.llmLocalPull('http://ollama:11434', 'x', vi.fn())) + .rejects.toThrow('Pull failed (200)') + }) +}) diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts new file mode 100644 index 000000000..5c0afe7f3 --- /dev/null +++ b/client/src/api/client.surface.test.ts @@ -0,0 +1,843 @@ +// FE-APISURF-001 to FE-APISURF-052 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import type { AxiosResponse } from 'axios' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { + apiClient, + authApi, oauthApi, tripsApi, daysApi, placesApi, assignmentsApi, packingApi, todoApi, + tagsApi, categoriesApi, adminApi, addonsApi, pluginsApi, airtrailApi, journeyApi, + mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, + configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, + shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, +} from './client' + +interface Recorded { method: string; url: string; body: unknown } + +let log: Recorded[] = [] + +/** One record per outgoing request: verb, path+query and (parsed) JSON body. */ +function recorder() { + return http.all(/\/api\//, async ({ request }) => { + const url = new URL(request.url) + const raw = await request.text() + let body: unknown + if (raw) { + try { body = JSON.parse(raw) } catch { body = raw } + } + log.push({ method: request.method, url: url.pathname + url.search, body }) + return HttpResponse.json({ ok: true }) + }) +} + +beforeEach(() => { + log = [] + server.use(recorder()) + // parseInDev/checkInDev warn on every stub payload that doesn't match its + // @trek/shared schema β€” expected here, so keep the output readable. + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface Call { n: string; r: () => Promise; e: string } + +/** Runs every call in isolation and checks the verb + path it produced. */ +async function assertCalls(calls: Call[]): Promise { + for (const c of calls) { + log = [] + await c.r() + expect(log.length, `${c.n}: expected exactly one request`).toBe(1) + const rec = log[0] + const [path] = rec.url.split('?') + expect(`${rec.method} ${path}`, c.n).toBe(c.e) + } +} + +/** Runs one call and returns the request it produced. */ +async function traceOne(run: () => Promise): Promise { + log = [] + await run() + expect(log).toHaveLength(1) + return log[0] +} + +describe('client > endpoint wiring', () => { + it('FE-APISURF-001: authApi maps every method to its auth endpoint', async () => { + await assertCalls([ + { n: 'register', r: () => authApi.register({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/register' }, + { n: 'validateInvite', r: () => authApi.validateInvite('inv-tok'), e: 'GET /api/auth/invite/inv-tok' }, + { n: 'login', r: () => authApi.login({ email: 'a@b.c', password: 'pw' }), e: 'POST /api/auth/login' }, + { n: 'verifyMfaLogin', r: () => authApi.verifyMfaLogin({ mfa_token: 'm', code: '123456' }), e: 'POST /api/auth/mfa/verify-login' }, + { n: 'mfaSetup', r: () => authApi.mfaSetup(), e: 'POST /api/auth/mfa/setup' }, + { n: 'mfaEnable', r: () => authApi.mfaEnable({ code: '123456' }), e: 'POST /api/auth/mfa/enable' }, + { n: 'mfaDisable', r: () => authApi.mfaDisable({ password: 'pw', code: '123456' }), e: 'POST /api/auth/mfa/disable' }, + { n: 'me', r: () => authApi.me(), e: 'GET /api/auth/me' }, + { n: 'updateMapsKey', r: () => authApi.updateMapsKey('gkey'), e: 'PUT /api/auth/me/maps-key' }, + { n: 'updateApiKeys', r: () => authApi.updateApiKeys({ google_maps: null }), e: 'PUT /api/auth/me/api-keys' }, + { n: 'updateSettings', r: () => authApi.updateSettings({ theme: 'dark' }), e: 'PUT /api/auth/me/settings' }, + { n: 'getSettings', r: () => authApi.getSettings(), e: 'GET /api/auth/me/settings' }, + { n: 'listUsers', r: () => authApi.listUsers(), e: 'GET /api/auth/users' }, + { n: 'deleteAvatar', r: () => authApi.deleteAvatar(), e: 'DELETE /api/auth/avatar' }, + { n: 'getAppConfig', r: () => authApi.getAppConfig(), e: 'GET /api/auth/app-config' }, + { n: 'updateAppSettings', r: () => authApi.updateAppSettings({ registration_enabled: true }), e: 'PUT /api/auth/app-settings' }, + { n: 'validateKeys', r: () => authApi.validateKeys(), e: 'GET /api/auth/validate-keys' }, + { n: 'travelStats', r: () => authApi.travelStats(), e: 'GET /api/auth/travel-stats' }, + { n: 'changePassword', r: () => authApi.changePassword({ current_password: 'a', new_password: 'b' }), e: 'PUT /api/auth/me/password' }, + { n: 'forgotPassword', r: () => authApi.forgotPassword({ email: 'a@b.c' }), e: 'POST /api/auth/forgot-password' }, + { n: 'resetPassword', r: () => authApi.resetPassword({ token: 't', new_password: 'b' }), e: 'POST /api/auth/reset-password' }, + { n: 'deleteOwnAccount', r: () => authApi.deleteOwnAccount(), e: 'DELETE /api/auth/me' }, + { n: 'demoLogin', r: () => authApi.demoLogin(), e: 'POST /api/auth/demo-login' }, + { n: 'mcpTokens.list', r: () => authApi.mcpTokens.list(), e: 'GET /api/auth/mcp-tokens' }, + { n: 'mcpTokens.create', r: () => authApi.mcpTokens.create('cli'), e: 'POST /api/auth/mcp-tokens' }, + { n: 'mcpTokens.delete', r: () => authApi.mcpTokens.delete(7), e: 'DELETE /api/auth/mcp-tokens/7' }, + { n: 'passkey.registerOptions', r: () => authApi.passkey.registerOptions('pw'), e: 'POST /api/auth/passkey/register/options' }, + { n: 'passkey.registerVerify', r: () => authApi.passkey.registerVerify({ id: 'cred' }, 'Yubikey'), e: 'POST /api/auth/passkey/register/verify' }, + { n: 'passkey.loginOptions', r: () => authApi.passkey.loginOptions(), e: 'POST /api/auth/passkey/login/options' }, + { n: 'passkey.loginVerify', r: () => authApi.passkey.loginVerify({ id: 'cred' }), e: 'POST /api/auth/passkey/login/verify' }, + { n: 'passkey.list', r: () => authApi.passkey.list(), e: 'GET /api/auth/passkey/credentials' }, + { n: 'passkey.rename', r: () => authApi.passkey.rename(3, 'Phone'), e: 'PATCH /api/auth/passkey/credentials/3' }, + { n: 'passkey.delete', r: () => authApi.passkey.delete(3, 'pw'), e: 'DELETE /api/auth/passkey/credentials/3' }, + ]) + }) + + it('FE-APISURF-002: oauthApi maps consent + client/session management endpoints', async () => { + const params = { + response_type: 'code', client_id: 'cid', redirect_uri: 'https://app/cb', + scope: 'trips:read', code_challenge: 'chal', code_challenge_method: 'S256', + } + await assertCalls([ + { n: 'validate', r: () => oauthApi.validate(params), e: 'GET /api/oauth/authorize/validate' }, + { n: 'authorize', r: () => oauthApi.authorize({ ...params, approved: true }), e: 'POST /api/oauth/authorize' }, + { n: 'clients.list', r: () => oauthApi.clients.list(), e: 'GET /api/oauth/clients' }, + { n: 'clients.create', r: () => oauthApi.clients.create({ name: 'App', allowed_scopes: ['trips:read'] }), e: 'POST /api/oauth/clients' }, + { n: 'clients.rotate', r: () => oauthApi.clients.rotate('cid'), e: 'POST /api/oauth/clients/cid/rotate' }, + { n: 'clients.delete', r: () => oauthApi.clients.delete('cid'), e: 'DELETE /api/oauth/clients/cid' }, + { n: 'sessions.list', r: () => oauthApi.sessions.list(), e: 'GET /api/oauth/sessions' }, + { n: 'sessions.revoke', r: () => oauthApi.sessions.revoke(4), e: 'DELETE /api/oauth/sessions/4' }, + ]) + }) + + it('FE-APISURF-003: tripsApi maps trip, member and guest endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => tripsApi.list(), e: 'GET /api/trips' }, + { n: 'create', r: () => tripsApi.create({ title: 'Rome' }), e: 'POST /api/trips' }, + { n: 'get', r: () => tripsApi.get(3), e: 'GET /api/trips/3' }, + { n: 'update', r: () => tripsApi.update(3, { title: 'Rome 2' }), e: 'PUT /api/trips/3' }, + { n: 'delete', r: () => tripsApi.delete(3), e: 'DELETE /api/trips/3' }, + { n: 'searchCoverImages', r: () => tripsApi.searchCoverImages('rome'), e: 'GET /api/trips/cover-images/search' }, + { n: 'archive', r: () => tripsApi.archive(3), e: 'PUT /api/trips/3' }, + { n: 'unarchive', r: () => tripsApi.unarchive(3), e: 'PUT /api/trips/3' }, + { n: 'getMembers', r: () => tripsApi.getMembers(3), e: 'GET /api/trips/3/members' }, + { n: 'addMember', r: () => tripsApi.addMember(3, 'bob'), e: 'POST /api/trips/3/members' }, + { n: 'removeMember', r: () => tripsApi.removeMember(3, 9), e: 'DELETE /api/trips/3/members/9' }, + { n: 'transferOwnership', r: () => tripsApi.transferOwnership(3, 9), e: 'POST /api/trips/3/transfer' }, + { n: 'createGuest', r: () => tripsApi.createGuest(3, 'Anna'), e: 'POST /api/trips/3/guests' }, + { n: 'renameGuest', r: () => tripsApi.renameGuest(3, 9, 'Ana'), e: 'PUT /api/trips/3/guests/9' }, + { n: 'deleteGuest', r: () => tripsApi.deleteGuest(3, 9), e: 'DELETE /api/trips/3/guests/9' }, + { n: 'copy', r: () => tripsApi.copy(3, { title: 'Copy' }), e: 'POST /api/trips/3/copy' }, + { n: 'bundle', r: () => tripsApi.bundle(3), e: 'GET /api/trips/3/bundle' }, + ]) + }) + + it('FE-APISURF-004: daysApi and dayNotesApi map their nested trip endpoints', async () => { + await assertCalls([ + { n: 'days.list', r: () => daysApi.list(1), e: 'GET /api/trips/1/days' }, + { n: 'days.create', r: () => daysApi.create(1, { date: '2026-06-01' }), e: 'POST /api/trips/1/days' }, + { n: 'days.update', r: () => daysApi.update(1, 2, { notes: 'hi' }), e: 'PUT /api/trips/1/days/2' }, + { n: 'days.updateTransport', r: () => daysApi.updateTransport(1, 2, 'car'), e: 'PUT /api/trips/1/days/2/transport' }, + { n: 'days.delete', r: () => daysApi.delete(1, 2), e: 'DELETE /api/trips/1/days/2' }, + { n: 'days.reorder', r: () => daysApi.reorder(1, [2, 1]), e: 'PUT /api/trips/1/days/reorder' }, + { n: 'dayNotes.list', r: () => dayNotesApi.list(1, 2), e: 'GET /api/trips/1/days/2/notes' }, + { n: 'dayNotes.create', r: () => dayNotesApi.create(1, 2, { text: 'note' }), e: 'POST /api/trips/1/days/2/notes' }, + { n: 'dayNotes.update', r: () => dayNotesApi.update(1, 2, 5, { text: 'edit' }), e: 'PUT /api/trips/1/days/2/notes/5' }, + { n: 'dayNotes.delete', r: () => dayNotesApi.delete(1, 2, 5), e: 'DELETE /api/trips/1/days/2/notes/5' }, + ]) + }) + + it('FE-APISURF-005: placesApi maps CRUD, rating and list-import endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => placesApi.list(1), e: 'GET /api/trips/1/places' }, + { n: 'create', r: () => placesApi.create(1, { name: 'Colosseum' }), e: 'POST /api/trips/1/places' }, + { n: 'get', r: () => placesApi.get(1, 5), e: 'GET /api/trips/1/places/5' }, + { n: 'update', r: () => placesApi.update(1, 5, { name: 'Forum' }), e: 'PUT /api/trips/1/places/5' }, + { n: 'delete', r: () => placesApi.delete(1, 5), e: 'DELETE /api/trips/1/places/5' }, + { n: 'searchImage', r: () => placesApi.searchImage(1, 5), e: 'GET /api/trips/1/places/5/image' }, + { n: 'importGoogleList', r: () => placesApi.importGoogleList(1, 'https://maps.app/x'), e: 'POST /api/trips/1/places/import/google-list' }, + { n: 'importNaverList', r: () => placesApi.importNaverList(1, 'https://naver/x'), e: 'POST /api/trips/1/places/import/naver-list' }, + { n: 'bulkDelete', r: () => placesApi.bulkDelete(1, [5, 6]), e: 'POST /api/trips/1/places/bulk-delete' }, + { n: 'bulkUpdate', r: () => placesApi.bulkUpdate(1, [5], { category_id: 2 }), e: 'POST /api/trips/1/places/bulk-update' }, + ]) + }) + + it('FE-APISURF-006: assignmentsApi maps day-plan endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => assignmentsApi.list(1, 2), e: 'GET /api/trips/1/days/2/assignments' }, + { n: 'create', r: () => assignmentsApi.create(1, 2, { place_id: 5 }), e: 'POST /api/trips/1/days/2/assignments' }, + { n: 'delete', r: () => assignmentsApi.delete(1, 2, 7), e: 'DELETE /api/trips/1/days/2/assignments/7' }, + { n: 'reorder', r: () => assignmentsApi.reorder(1, 2, [7, 8]), e: 'PUT /api/trips/1/days/2/assignments/reorder' }, + { n: 'move', r: () => assignmentsApi.move(1, 7, 3, 0), e: 'PUT /api/trips/1/assignments/7/move' }, + { n: 'update', r: () => assignmentsApi.update(1, 2, 7, { notes: 'x' }), e: 'PUT /api/trips/1/days/2/assignments/7' }, + { n: 'getParticipants', r: () => assignmentsApi.getParticipants(1, 7), e: 'GET /api/trips/1/assignments/7/participants' }, + { n: 'setParticipants', r: () => assignmentsApi.setParticipants(1, 7, [4]), e: 'PUT /api/trips/1/assignments/7/participants' }, + { n: 'updateTime', r: () => assignmentsApi.updateTime(1, 7, { place_time: '09:00' }), e: 'PUT /api/trips/1/assignments/7/time' }, + { n: 'updateTransport', r: () => assignmentsApi.updateTransport(1, 7, null), e: 'PUT /api/trips/1/assignments/7/transport' }, + ]) + }) + + it('FE-APISURF-007: packingApi maps item, bag and template endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => packingApi.list(1), e: 'GET /api/trips/1/packing' }, + { n: 'create', r: () => packingApi.create(1, { name: 'Towel' }), e: 'POST /api/trips/1/packing' }, + { n: 'bulkImport', r: () => packingApi.bulkImport(1, [{ name: 'Socks' }]), e: 'POST /api/trips/1/packing/import' }, + { n: 'update', r: () => packingApi.update(1, 4, { checked: true }), e: 'PUT /api/trips/1/packing/4' }, + { n: 'delete', r: () => packingApi.delete(1, 4), e: 'DELETE /api/trips/1/packing/4' }, + { n: 'reorder', r: () => packingApi.reorder(1, [4, 5]), e: 'PUT /api/trips/1/packing/reorder' }, + { n: 'setSharing', r: () => packingApi.setSharing(1, 4, { visibility: 'shared' }), e: 'PUT /api/trips/1/packing/4/sharing' }, + { n: 'clone', r: () => packingApi.clone(1, 4), e: 'POST /api/trips/1/packing/4/clone' }, + { n: 'addContributor', r: () => packingApi.addContributor(1, 4), e: 'POST /api/trips/1/packing/4/contributors' }, + { n: 'removeContributor', r: () => packingApi.removeContributor(1, 4, 9), e: 'DELETE /api/trips/1/packing/4/contributors/9' }, + { n: 'getCategoryAssignees', r: () => packingApi.getCategoryAssignees(1), e: 'GET /api/trips/1/packing/category-assignees' }, + { n: 'listTemplates', r: () => packingApi.listTemplates(1), e: 'GET /api/trips/1/packing/templates' }, + { n: 'applyTemplate', r: () => packingApi.applyTemplate(1, 6), e: 'POST /api/trips/1/packing/apply-template/6' }, + { n: 'saveAsTemplate', r: () => packingApi.saveAsTemplate(1, 'Beach'), e: 'POST /api/trips/1/packing/save-as-template' }, + { n: 'setBagMembers', r: () => packingApi.setBagMembers(1, 2, [9]), e: 'PUT /api/trips/1/packing/bags/2/members' }, + { n: 'listBags', r: () => packingApi.listBags(1), e: 'GET /api/trips/1/packing/bags' }, + { n: 'createBag', r: () => packingApi.createBag(1, { name: 'Carry-on' }), e: 'POST /api/trips/1/packing/bags' }, + { n: 'updateBag', r: () => packingApi.updateBag(1, 2, { name: 'Hold' }), e: 'PUT /api/trips/1/packing/bags/2' }, + { n: 'deleteBag', r: () => packingApi.deleteBag(1, 2), e: 'DELETE /api/trips/1/packing/bags/2' }, + ]) + }) + + it('FE-APISURF-008: todoApi maps todo endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => todoApi.list(1), e: 'GET /api/trips/1/todo' }, + { n: 'create', r: () => todoApi.create(1, { name: 'Book train' }), e: 'POST /api/trips/1/todo' }, + { n: 'update', r: () => todoApi.update(1, 3, { checked: true }), e: 'PUT /api/trips/1/todo/3' }, + { n: 'delete', r: () => todoApi.delete(1, 3), e: 'DELETE /api/trips/1/todo/3' }, + { n: 'reorder', r: () => todoApi.reorder(1, [3, 4]), e: 'PUT /api/trips/1/todo/reorder' }, + { n: 'getCategoryAssignees', r: () => todoApi.getCategoryAssignees(1), e: 'GET /api/trips/1/todo/category-assignees' }, + ]) + }) + + it('FE-APISURF-009: tagsApi and categoriesApi map their global endpoints', async () => { + await assertCalls([ + { n: 'tags.list', r: () => tagsApi.list(), e: 'GET /api/tags' }, + { n: 'tags.create', r: () => tagsApi.create({ name: 'Food' }), e: 'POST /api/tags' }, + { n: 'tags.update', r: () => tagsApi.update(2, { name: 'Eat' }), e: 'PUT /api/tags/2' }, + { n: 'tags.delete', r: () => tagsApi.delete(2), e: 'DELETE /api/tags/2' }, + { n: 'categories.list', r: () => categoriesApi.list(), e: 'GET /api/categories' }, + { n: 'categories.create', r: () => categoriesApi.create({ name: 'Museum' }), e: 'POST /api/categories' }, + { n: 'categories.update', r: () => categoriesApi.update(2, { name: 'Art' }), e: 'PUT /api/categories/2' }, + { n: 'categories.delete', r: () => categoriesApi.delete(2), e: 'DELETE /api/categories/2' }, + ]) + }) + + it('FE-APISURF-010: adminApi maps user, addon and settings endpoints', async () => { + await assertCalls([ + { n: 'users', r: () => adminApi.users(), e: 'GET /api/admin/users' }, + { n: 'createUser', r: () => adminApi.createUser({ email: 'a@b.c' }), e: 'POST /api/admin/users' }, + { n: 'updateUser', r: () => adminApi.updateUser(2, { role: 'admin' }), e: 'PUT /api/admin/users/2' }, + { n: 'deleteUser', r: () => adminApi.deleteUser(2), e: 'DELETE /api/admin/users/2' }, + { n: 'resetUserPasskeys', r: () => adminApi.resetUserPasskeys(2), e: 'DELETE /api/admin/users/2/passkeys' }, + { n: 'stats', r: () => adminApi.stats(), e: 'GET /api/admin/stats' }, + { n: 'saveDemoBaseline', r: () => adminApi.saveDemoBaseline(), e: 'POST /api/admin/save-demo-baseline' }, + { n: 'getOidc', r: () => adminApi.getOidc(), e: 'GET /api/admin/oidc' }, + { n: 'updateOidc', r: () => adminApi.updateOidc({ enabled: true }), e: 'PUT /api/admin/oidc' }, + { n: 'addons', r: () => adminApi.addons(), e: 'GET /api/admin/addons' }, + { n: 'updateAddon', r: () => adminApi.updateAddon(3, { enabled: false }), e: 'PUT /api/admin/addons/3' }, + { n: 'checkVersion', r: () => adminApi.checkVersion(), e: 'GET /api/admin/version-check' }, + { n: 'getBagTracking', r: () => adminApi.getBagTracking(), e: 'GET /api/admin/bag-tracking' }, + { n: 'updateBagTracking', r: () => adminApi.updateBagTracking(true), e: 'PUT /api/admin/bag-tracking' }, + { n: 'getPlacesPhotos', r: () => adminApi.getPlacesPhotos(), e: 'GET /api/admin/places-photos' }, + { n: 'updatePlacesPhotos', r: () => adminApi.updatePlacesPhotos(false), e: 'PUT /api/admin/places-photos' }, + { n: 'getPlacesAutocomplete', r: () => adminApi.getPlacesAutocomplete(), e: 'GET /api/admin/places-autocomplete' }, + { n: 'updatePlacesAutocomplete', r: () => adminApi.updatePlacesAutocomplete(true), e: 'PUT /api/admin/places-autocomplete' }, + { n: 'getPlacesDetails', r: () => adminApi.getPlacesDetails(), e: 'GET /api/admin/places-details' }, + { n: 'updatePlacesDetails', r: () => adminApi.updatePlacesDetails(true), e: 'PUT /api/admin/places-details' }, + { n: 'getCollabFeatures', r: () => adminApi.getCollabFeatures(), e: 'GET /api/admin/collab-features' }, + { n: 'updateCollabFeatures', r: () => adminApi.updateCollabFeatures({ polls: true }), e: 'PUT /api/admin/collab-features' }, + { n: 'getPermissions', r: () => adminApi.getPermissions(), e: 'GET /api/admin/permissions' }, + { n: 'updatePermissions', r: () => adminApi.updatePermissions({ edit_trip: 'member' }), e: 'PUT /api/admin/permissions' }, + { n: 'rotateJwtSecret', r: () => adminApi.rotateJwtSecret(), e: 'POST /api/admin/rotate-jwt-secret' }, + { n: 'sendTestNotification', r: () => adminApi.sendTestNotification({ channel: 'email' }), e: 'POST /api/admin/dev/test-notification' }, + { n: 'getNotificationPreferences', r: () => adminApi.getNotificationPreferences(), e: 'GET /api/admin/notification-preferences' }, + { n: 'updateNotificationPreferences', r: () => adminApi.updateNotificationPreferences({ email: { trip_invite: true } }), e: 'PUT /api/admin/notification-preferences' }, + { n: 'getDefaultUserSettings', r: () => adminApi.getDefaultUserSettings(), e: 'GET /api/admin/default-user-settings' }, + { n: 'updateDefaultUserSettings', r: () => adminApi.updateDefaultUserSettings({ language: 'de' }), e: 'PUT /api/admin/default-user-settings' }, + { n: 'mcpTokens', r: () => adminApi.mcpTokens(), e: 'GET /api/admin/mcp-tokens' }, + { n: 'deleteMcpToken', r: () => adminApi.deleteMcpToken(4), e: 'DELETE /api/admin/mcp-tokens/4' }, + { n: 'oauthSessions', r: () => adminApi.oauthSessions(), e: 'GET /api/admin/oauth-sessions' }, + { n: 'revokeOAuthSession', r: () => adminApi.revokeOAuthSession(4), e: 'DELETE /api/admin/oauth-sessions/4' }, + { n: 'listInvites', r: () => adminApi.listInvites(), e: 'GET /api/admin/invites' }, + { n: 'listInviteTrips', r: () => adminApi.listInviteTrips(), e: 'GET /api/admin/invites/trips' }, + { n: 'createInvite', r: () => adminApi.createInvite({ max_uses: 3 }), e: 'POST /api/admin/invites' }, + { n: 'deleteInvite', r: () => adminApi.deleteInvite(8), e: 'DELETE /api/admin/invites/8' }, + { n: 'auditLog', r: () => adminApi.auditLog(), e: 'GET /api/admin/audit-log' }, + ]) + }) + + it('FE-APISURF-011: adminApi maps the plugin management endpoints', async () => { + await assertCalls([ + { n: 'plugins', r: () => adminApi.plugins(), e: 'GET /api/admin/plugins' }, + { n: 'pluginBrowse', r: () => adminApi.pluginBrowse(), e: 'GET /api/admin/plugins/registry' }, + { n: 'pluginDetail', r: () => adminApi.pluginDetail('trek/koffi'), e: 'GET /api/admin/plugins/registry/trek%2Fkoffi' }, + { n: 'pluginInstall', r: () => adminApi.pluginInstall('koffi', { version: '1.0.0' }), e: 'POST /api/admin/plugins/install' }, + { n: 'pluginActivate', r: () => adminApi.pluginActivate('koffi'), e: 'POST /api/admin/plugins/koffi/activate' }, + { n: 'pluginDeactivate', r: () => adminApi.pluginDeactivate('koffi'), e: 'POST /api/admin/plugins/koffi/deactivate' }, + { n: 'pluginUpdate', r: () => adminApi.pluginUpdate('koffi'), e: 'POST /api/admin/plugins/koffi/update' }, + { n: 'pluginRetrust', r: () => adminApi.pluginRetrust('koffi', '2.0.0', 'PUBKEY'), e: 'POST /api/admin/plugins/koffi/retrust' }, + { n: 'pluginUninstall', r: () => adminApi.pluginUninstall('koffi', true), e: 'POST /api/admin/plugins/koffi/uninstall' }, + { n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' }, + { n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' }, + { n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' }, + { n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' }, + { n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' }, + { n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' }, + { n: 'pluginAudit', r: () => adminApi.pluginAudit('koffi'), e: 'GET /api/admin/plugins/koffi/audit' }, + { n: 'llmLocalModels', r: () => adminApi.llmLocalModels('http://ollama:11434'), e: 'GET /api/admin/llm/local/models' }, + ]) + }) + + it('FE-APISURF-012: adminApi maps the packing-template endpoints', async () => { + await assertCalls([ + { n: 'packingTemplates', r: () => adminApi.packingTemplates(), e: 'GET /api/admin/packing-templates' }, + { n: 'getPackingTemplate', r: () => adminApi.getPackingTemplate(1), e: 'GET /api/admin/packing-templates/1' }, + { n: 'createPackingTemplate', r: () => adminApi.createPackingTemplate({ name: 'Ski' }), e: 'POST /api/admin/packing-templates' }, + { n: 'updatePackingTemplate', r: () => adminApi.updatePackingTemplate(1, { name: 'Ski 2' }), e: 'PUT /api/admin/packing-templates/1' }, + { n: 'deletePackingTemplate', r: () => adminApi.deletePackingTemplate(1), e: 'DELETE /api/admin/packing-templates/1' }, + { n: 'addTemplateCategory', r: () => adminApi.addTemplateCategory(1, { name: 'Clothes' }), e: 'POST /api/admin/packing-templates/1/categories' }, + { n: 'updateTemplateCategory', r: () => adminApi.updateTemplateCategory(1, 2, { name: 'Wear' }), e: 'PUT /api/admin/packing-templates/1/categories/2' }, + { n: 'deleteTemplateCategory', r: () => adminApi.deleteTemplateCategory(1, 2), e: 'DELETE /api/admin/packing-templates/1/categories/2' }, + { n: 'addTemplateItem', r: () => adminApi.addTemplateItem(1, 2, { name: 'Gloves' }), e: 'POST /api/admin/packing-templates/1/categories/2/items' }, + { n: 'updateTemplateItem', r: () => adminApi.updateTemplateItem(1, 3, { name: 'Mittens' }), e: 'PUT /api/admin/packing-templates/1/items/3' }, + { n: 'deleteTemplateItem', r: () => adminApi.deleteTemplateItem(1, 3), e: 'DELETE /api/admin/packing-templates/1/items/3' }, + ]) + }) + + it('FE-APISURF-013: pluginsApi maps every host-mediated plugin endpoint', async () => { + await assertCalls([ + { n: 'active', r: () => pluginsApi.active(), e: 'GET /api/plugins' }, + { n: 'placeDetails', r: () => pluginsApi.placeDetails(5), e: 'GET /api/place-details/5' }, + { n: 'tripWarnings', r: () => pluginsApi.tripWarnings(1), e: 'GET /api/trip-warnings/1' }, + { n: 'viewContributions', r: () => pluginsApi.viewContributions('places', 1), e: 'GET /api/view-contributions/places/1' }, + { n: 'mapMarkers', r: () => pluginsApi.mapMarkers(1), e: 'GET /api/map-markers/1' }, + { n: 'mapLayers', r: () => pluginsApi.mapLayers(1), e: 'GET /api/map-layers/1' }, + { n: 'pluginRoute', r: () => pluginsApi.pluginRoute('koffi', 'ev', { tripId: 1, waypoints: [{ lat: 1, lng: 2 }] }), e: 'POST /api/plugin-routes/koffi/ev' }, + { n: 'daySchedule', r: () => pluginsApi.daySchedule(1), e: 'GET /api/day-schedule/1' }, + { n: 'pdfSections', r: () => pluginsApi.pdfSections(1), e: 'GET /api/pdf-sections/1' }, + { n: 'atlasLayers', r: () => pluginsApi.atlasLayers(), e: 'GET /api/atlas-layers' }, + { n: 'journalEntryRows', r: () => pluginsApi.journalEntryRows(9), e: 'GET /api/journal-entry-rows/9' }, + { n: 'tripCardContributions', r: () => pluginsApi.tripCardContributions([1, 2]), e: 'GET /api/trip-card-contributions' }, + { n: 'myActivity', r: () => pluginsApi.myActivity(), e: 'GET /api/plugin-activity' }, + { n: 'userSettings', r: () => pluginsApi.userSettings('koffi'), e: 'GET /api/plugin-settings/koffi' }, + { n: 'runAction', r: () => pluginsApi.runAction('koffi', 'test connection'), e: 'POST /api/plugin-settings/koffi/actions/test%20connection' }, + { n: 'saveUserSettings', r: () => pluginsApi.saveUserSettings('koffi', { key: 'v' }), e: 'POST /api/plugin-settings/koffi' }, + { n: 'oauthStatus', r: () => pluginsApi.oauthStatus('koffi'), e: 'GET /api/plugin-oauth/koffi/status' }, + { n: 'oauthConnect', r: () => pluginsApi.oauthConnect('koffi'), e: 'POST /api/plugin-oauth/koffi/connect' }, + { n: 'oauthDisconnect', r: () => pluginsApi.oauthDisconnect('koffi'), e: 'POST /api/plugin-oauth/koffi/disconnect' }, + ]) + }) + + it('FE-APISURF-014: airtrailApi maps the integration endpoints', async () => { + await assertCalls([ + { n: 'getSettings', r: () => airtrailApi.getSettings(), e: 'GET /api/integrations/airtrail/settings' }, + { n: 'saveSettings', r: () => airtrailApi.saveSettings({ url: 'https://at' }), e: 'PUT /api/integrations/airtrail/settings' }, + { n: 'status', r: () => airtrailApi.status(), e: 'GET /api/integrations/airtrail/status' }, + { n: 'test', r: () => airtrailApi.test({ url: 'https://at' }), e: 'POST /api/integrations/airtrail/test' }, + { n: 'sync', r: () => airtrailApi.sync(), e: 'POST /api/integrations/airtrail/sync' }, + { n: 'flights', r: () => airtrailApi.flights(), e: 'GET /api/integrations/airtrail/flights' }, + { n: 'import', r: () => airtrailApi.import(1, ['f1']), e: 'POST /api/trips/1/reservations/import/airtrail' }, + ]) + }) + + it('FE-APISURF-015: journeyApi maps journal, entry and photo endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => journeyApi.list(), e: 'GET /api/journeys' }, + { n: 'create', r: () => journeyApi.create({ title: 'Asia' }), e: 'POST /api/journeys' }, + { n: 'get', r: () => journeyApi.get(2), e: 'GET /api/journeys/2' }, + { n: 'update', r: () => journeyApi.update(2, { title: 'Asia 24' }), e: 'PATCH /api/journeys/2' }, + { n: 'delete', r: () => journeyApi.delete(2), e: 'DELETE /api/journeys/2' }, + { n: 'suggestions', r: () => journeyApi.suggestions(), e: 'GET /api/journeys/suggestions' }, + { n: 'availableTrips', r: () => journeyApi.availableTrips(), e: 'GET /api/journeys/available-trips' }, + { n: 'addTrip', r: () => journeyApi.addTrip(2, 1), e: 'POST /api/journeys/2/trips' }, + { n: 'removeTrip', r: () => journeyApi.removeTrip(2, 1), e: 'DELETE /api/journeys/2/trips/1' }, + { n: 'listEntries', r: () => journeyApi.listEntries(2), e: 'GET /api/journeys/2/entries' }, + { n: 'createEntry', r: () => journeyApi.createEntry(2, { title: 'Day 1' }), e: 'POST /api/journeys/2/entries' }, + { n: 'updateEntry', r: () => journeyApi.updateEntry(9, { title: 'Day 2' }), e: 'PATCH /api/journeys/entries/9' }, + { n: 'deleteEntry', r: () => journeyApi.deleteEntry(9), e: 'DELETE /api/journeys/entries/9' }, + { n: 'reorderEntries', r: () => journeyApi.reorderEntries(2, [9, 8]), e: 'PUT /api/journeys/2/entries/reorder' }, + { n: 'addProviderPhotosToGallery', r: () => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']), e: 'POST /api/journeys/2/gallery/provider-photos' }, + { n: 'addProviderPhoto', r: () => journeyApi.addProviderPhoto(9, 'immich', 'a1'), e: 'POST /api/journeys/entries/9/provider-photos' }, + { n: 'addProviderPhotos', r: () => journeyApi.addProviderPhotos(9, 'immich', ['a1']), e: 'POST /api/journeys/entries/9/provider-photos' }, + { n: 'linkPhoto', r: () => journeyApi.linkPhoto(9, 11), e: 'POST /api/journeys/entries/9/link-photo' }, + { n: 'unlinkPhoto', r: () => journeyApi.unlinkPhoto(9, 11), e: 'DELETE /api/journeys/entries/9/photos/11' }, + { n: 'deleteGalleryPhoto', r: () => journeyApi.deleteGalleryPhoto(2, 11), e: 'DELETE /api/journeys/2/gallery/11' }, + { n: 'updatePhoto', r: () => journeyApi.updatePhoto(11, { caption: 'x' }), e: 'PATCH /api/journeys/photos/11' }, + { n: 'deletePhoto', r: () => journeyApi.deletePhoto(11), e: 'DELETE /api/journeys/photos/11' }, + { n: 'addContributor', r: () => journeyApi.addContributor(2, 4, 'editor'), e: 'POST /api/journeys/2/contributors' }, + { n: 'updateContributor', r: () => journeyApi.updateContributor(2, 4, 'viewer'), e: 'PATCH /api/journeys/2/contributors/4' }, + { n: 'removeContributor', r: () => journeyApi.removeContributor(2, 4), e: 'DELETE /api/journeys/2/contributors/4' }, + { n: 'updatePreferences', r: () => journeyApi.updatePreferences(2, { hide_skeletons: true }), e: 'PATCH /api/journeys/2/preferences' }, + { n: 'getShareLink', r: () => journeyApi.getShareLink(2), e: 'GET /api/journeys/2/share-link' }, + { n: 'createShareLink', r: () => journeyApi.createShareLink(2, { share_map: true }), e: 'POST /api/journeys/2/share-link' }, + { n: 'deleteShareLink', r: () => journeyApi.deleteShareLink(2), e: 'DELETE /api/journeys/2/share-link' }, + { n: 'getPublicJourney', r: () => journeyApi.getPublicJourney('pub-tok'), e: 'GET /api/public/journey/pub-tok' }, + ]) + }) + + it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => { + await assertCalls([ + { n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' }, + { n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' }, + { n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' }, + { n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' }, + { n: 'maps.reverse', r: () => mapsApi.reverse(41.9, 12.5), e: 'GET /api/maps/reverse' }, + { n: 'maps.resolveUrl', r: () => mapsApi.resolveUrl('https://maps.app.goo.gl/x'), e: 'POST /api/maps/resolve-url' }, + { n: 'maps.pois', r: () => mapsApi.pois('cafe', { south: 1, west: 2, north: 3, east: 4 }), e: 'GET /api/maps/pois' }, + { n: 'airports.search', r: () => airportsApi.search('BER'), e: 'GET /api/airports/search' }, + { n: 'airports.byIata', r: () => airportsApi.byIata('b/er'), e: 'GET /api/airports/b%2Fer' }, + ]) + }) + + it('FE-APISURF-017: budgetApi maps item, member and settlement endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => budgetApi.list(1), e: 'GET /api/trips/1/budget' }, + { n: 'create', r: () => budgetApi.create(1, { name: 'Hotel' }), e: 'POST /api/trips/1/budget' }, + { n: 'update', r: () => budgetApi.update(1, 2, { name: 'Hostel' }), e: 'PUT /api/trips/1/budget/2' }, + { n: 'delete', r: () => budgetApi.delete(1, 2), e: 'DELETE /api/trips/1/budget/2' }, + { n: 'setMembers', r: () => budgetApi.setMembers(1, 2, [4, 5]), e: 'PUT /api/trips/1/budget/2/members' }, + { n: 'togglePaid', r: () => budgetApi.togglePaid(1, 2, 4, true), e: 'PUT /api/trips/1/budget/2/members/4/paid' }, + { n: 'setPayers', r: () => budgetApi.setPayers(1, 2, [{ user_id: 4, amount: 10 }]), e: 'PUT /api/trips/1/budget/2/payers' }, + { n: 'perPersonSummary', r: () => budgetApi.perPersonSummary(1), e: 'GET /api/trips/1/budget/summary/per-person' }, + { n: 'settlement', r: () => budgetApi.settlement(1), e: 'GET /api/trips/1/budget/settlement' }, + { n: 'createSettlement', r: () => budgetApi.createSettlement(1, { from_user_id: 4, to_user_id: 5, amount: 10 }), e: 'POST /api/trips/1/budget/settlements' }, + { n: 'updateSettlement', r: () => budgetApi.updateSettlement(1, 6, { from_user_id: 4, to_user_id: 5, amount: 12 }), e: 'PUT /api/trips/1/budget/settlements/6' }, + { n: 'deleteSettlement', r: () => budgetApi.deleteSettlement(1, 6), e: 'DELETE /api/trips/1/budget/settlements/6' }, + { n: 'reorderItems', r: () => budgetApi.reorderItems(1, [2, 3]), e: 'PUT /api/trips/1/budget/reorder/items' }, + { n: 'reorderCategories', r: () => budgetApi.reorderCategories(1, ['Food']), e: 'PUT /api/trips/1/budget/reorder/categories' }, + ]) + }) + + it('FE-APISURF-018: filesApi maps file, trash and link endpoints', async () => { + await assertCalls([ + { n: 'list', r: () => filesApi.list(1), e: 'GET /api/trips/1/files' }, + { n: 'update', r: () => filesApi.update(1, 3, { description: 'x' }), e: 'PUT /api/trips/1/files/3' }, + { n: 'delete', r: () => filesApi.delete(1, 3), e: 'DELETE /api/trips/1/files/3' }, + { n: 'toggleStar', r: () => filesApi.toggleStar(1, 3), e: 'PATCH /api/trips/1/files/3/star' }, + { n: 'restore', r: () => filesApi.restore(1, 3), e: 'POST /api/trips/1/files/3/restore' }, + { n: 'permanentDelete', r: () => filesApi.permanentDelete(1, 3), e: 'DELETE /api/trips/1/files/3/permanent' }, + { n: 'emptyTrash', r: () => filesApi.emptyTrash(1), e: 'DELETE /api/trips/1/files/trash/empty' }, + { n: 'addLink', r: () => filesApi.addLink(1, 3, { place_id: 5 }), e: 'POST /api/trips/1/files/3/link' }, + { n: 'removeLink', r: () => filesApi.removeLink(1, 3, 7), e: 'DELETE /api/trips/1/files/3/link/7' }, + { n: 'getLinks', r: () => filesApi.getLinks(1, 3), e: 'GET /api/trips/1/files/3/links' }, + ]) + }) + + it('FE-APISURF-019: reservationsApi and accommodationsApi map booking endpoints', async () => { + await assertCalls([ + { n: 'reservations.list', r: () => reservationsApi.list(1), e: 'GET /api/trips/1/reservations' }, + { n: 'reservations.upcoming', r: () => reservationsApi.upcoming(), e: 'GET /api/reservations/upcoming' }, + { n: 'reservations.create', r: () => reservationsApi.create(1, { title: 'Hotel' }), e: 'POST /api/trips/1/reservations' }, + { n: 'reservations.update', r: () => reservationsApi.update(1, 2, { title: 'Hostel' }), e: 'PUT /api/trips/1/reservations/2' }, + { n: 'reservations.delete', r: () => reservationsApi.delete(1, 2), e: 'DELETE /api/trips/1/reservations/2' }, + { n: 'reservations.setTravelers', r: () => reservationsApi.setTravelers(1, 2, [4]), e: 'PUT /api/trips/1/reservations/2/travelers' }, + { n: 'reservations.updatePositions', r: () => reservationsApi.updatePositions(1, [{ id: 2, day_plan_position: 0 }], 3), e: 'PUT /api/trips/1/reservations/positions' }, + { n: 'reservations.importBookingConfirm', r: () => reservationsApi.importBookingConfirm(1, []), e: 'POST /api/trips/1/reservations/import/booking/confirm' }, + { n: 'reservations.importJobStatus', r: () => reservationsApi.importJobStatus(1, 'job-1'), e: 'GET /api/trips/1/reservations/import/jobs/job-1' }, + { n: 'accommodations.list', r: () => accommodationsApi.list(1), e: 'GET /api/trips/1/accommodations' }, + { n: 'accommodations.create', r: () => accommodationsApi.create(1, { place_id: 5, start_day_id: 1, end_day_id: 2 }), e: 'POST /api/trips/1/accommodations' }, + { n: 'accommodations.update', r: () => accommodationsApi.update(1, 4, { end_day_id: 3 }), e: 'PUT /api/trips/1/accommodations/4' }, + { n: 'accommodations.delete', r: () => accommodationsApi.delete(1, 4), e: 'DELETE /api/trips/1/accommodations/4' }, + ]) + }) + + it('FE-APISURF-020: collabApi maps note, poll and message endpoints', async () => { + await assertCalls([ + { n: 'getNotes', r: () => collabApi.getNotes(1), e: 'GET /api/trips/1/collab/notes' }, + { n: 'createNote', r: () => collabApi.createNote(1, { title: 'Ideas' }), e: 'POST /api/trips/1/collab/notes' }, + { n: 'updateNote', r: () => collabApi.updateNote(1, 2, { title: 'More' }), e: 'PUT /api/trips/1/collab/notes/2' }, + { n: 'deleteNote', r: () => collabApi.deleteNote(1, 2), e: 'DELETE /api/trips/1/collab/notes/2' }, + { n: 'deleteNoteFile', r: () => collabApi.deleteNoteFile(1, 2, 3), e: 'DELETE /api/trips/1/collab/notes/2/files/3' }, + { n: 'getPolls', r: () => collabApi.getPolls(1), e: 'GET /api/trips/1/collab/polls' }, + { n: 'createPoll', r: () => collabApi.createPoll(1, { question: 'Where?', options: ['A', 'B'] }), e: 'POST /api/trips/1/collab/polls' }, + { n: 'votePoll', r: () => collabApi.votePoll(1, 2, 1), e: 'POST /api/trips/1/collab/polls/2/vote' }, + { n: 'closePoll', r: () => collabApi.closePoll(1, 2), e: 'PUT /api/trips/1/collab/polls/2/close' }, + { n: 'deletePoll', r: () => collabApi.deletePoll(1, 2), e: 'DELETE /api/trips/1/collab/polls/2' }, + { n: 'getMessages', r: () => collabApi.getMessages(1), e: 'GET /api/trips/1/collab/messages' }, + { n: 'sendMessage', r: () => collabApi.sendMessage(1, { text: 'hi' }), e: 'POST /api/trips/1/collab/messages' }, + { n: 'deleteMessage', r: () => collabApi.deleteMessage(1, 2), e: 'DELETE /api/trips/1/collab/messages/2' }, + { n: 'reactMessage', r: () => collabApi.reactMessage(1, 2, 'πŸ‘'), e: 'POST /api/trips/1/collab/messages/2/react' }, + { n: 'linkPreview', r: () => collabApi.linkPreview(1, 'https://x.test/a?b=1'), e: 'GET /api/trips/1/collab/link-preview' }, + ]) + }) + + it('FE-APISURF-021: the remaining namespaces map their endpoints', async () => { + await assertCalls([ + { n: 'addons.enabled', r: () => addonsApi.enabled(), e: 'GET /api/addons' }, + { n: 'health.features', r: () => healthApi.features(), e: 'GET /api/health/features' }, + { n: 'weather.get', r: () => weatherApi.get(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather' }, + { n: 'weather.getCurrent', r: () => weatherApi.getCurrent(41.9, 12.5), e: 'GET /api/weather' }, + { n: 'weather.getDetailed', r: () => weatherApi.getDetailed(41.9, 12.5, '2026-06-01'), e: 'GET /api/weather/detailed' }, + { n: 'config.getPublicConfig', r: () => configApi.getPublicConfig(), e: 'GET /api/config' }, + { n: 'help.index', r: () => helpApi.index(), e: 'GET /api/help/index' }, + { n: 'help.page', r: () => helpApi.page('getting started'), e: 'GET /api/help/page/getting%20started' }, + { n: 'settings.get', r: () => settingsApi.get(), e: 'GET /api/settings' }, + { n: 'settings.set', r: () => settingsApi.set('theme', 'dark'), e: 'PUT /api/settings' }, + { n: 'settings.setBulk', r: () => settingsApi.setBulk({ theme: 'dark' }), e: 'POST /api/settings/bulk' }, + { n: 'backup.list', r: () => backupApi.list(), e: 'GET /api/backup/list' }, + { n: 'backup.create', r: () => backupApi.create(), e: 'POST /api/backup/create' }, + { n: 'backup.delete', r: () => backupApi.delete('b.zip'), e: 'DELETE /api/backup/b.zip' }, + { n: 'backup.restore', r: () => backupApi.restore('b.zip'), e: 'POST /api/backup/restore/b.zip' }, + { n: 'backup.getAutoSettings', r: () => backupApi.getAutoSettings(), e: 'GET /api/backup/auto-settings' }, + { n: 'backup.setAutoSettings', r: () => backupApi.setAutoSettings({ enabled: true }), e: 'PUT /api/backup/auto-settings' }, + { n: 'share.getLink', r: () => shareApi.getLink(1), e: 'GET /api/trips/1/share-link' }, + { n: 'share.createLink', r: () => shareApi.createLink(1, { edit: false }), e: 'POST /api/trips/1/share-link' }, + { n: 'share.deleteLink', r: () => shareApi.deleteLink(1), e: 'DELETE /api/trips/1/share-link' }, + { n: 'share.getSharedTrip', r: () => shareApi.getSharedTrip('tok'), e: 'GET /api/shared/tok' }, + { n: 'transit.geocode', r: () => transitApi.geocode('Roma Termini'), e: 'GET /api/transit/geocode' }, + { n: 'transit.plan', r: () => transitApi.plan({ from: 'a', to: 'b' }), e: 'GET /api/transit/plan' }, + { n: 'tripInvite.getLink', r: () => tripInviteApi.getLink(1), e: 'GET /api/trips/1/invite-link' }, + { n: 'tripInvite.createLink', r: () => tripInviteApi.createLink(1, 7), e: 'POST /api/trips/1/invite-link' }, + { n: 'tripInvite.deleteLink', r: () => tripInviteApi.deleteLink(1), e: 'DELETE /api/trips/1/invite-link' }, + { n: 'tripInvite.preview', r: () => tripInviteApi.preview('tok'), e: 'GET /api/trip-invites/tok' }, + { n: 'tripInvite.accept', r: () => tripInviteApi.accept('tok'), e: 'POST /api/trip-invites/tok/accept' }, + { n: 'notifications.getPreferences', r: () => notificationsApi.getPreferences(), e: 'GET /api/notifications/preferences' }, + { n: 'notifications.updatePreferences', r: () => notificationsApi.updatePreferences({ email: { trip_invite: true } }), e: 'PUT /api/notifications/preferences' }, + { n: 'notifications.testSmtp', r: () => notificationsApi.testSmtp('a@b.c'), e: 'POST /api/notifications/test-smtp' }, + { n: 'notifications.testWebhook', r: () => notificationsApi.testWebhook('https://hook'), e: 'POST /api/notifications/test-webhook' }, + { n: 'notifications.testNtfy', r: () => notificationsApi.testNtfy({ topic: 't' }), e: 'POST /api/notifications/test-ntfy' }, + { n: 'notifications.testChannel', r: () => notificationsApi.testChannel('plugin/ch'), e: 'POST /api/notifications/test/plugin%2Fch' }, + { n: 'inApp.list', r: () => inAppNotificationsApi.list(), e: 'GET /api/notifications/in-app' }, + { n: 'inApp.unreadCount', r: () => inAppNotificationsApi.unreadCount(), e: 'GET /api/notifications/in-app/unread-count' }, + { n: 'inApp.markRead', r: () => inAppNotificationsApi.markRead(3), e: 'PUT /api/notifications/in-app/3/read' }, + { n: 'inApp.markUnread', r: () => inAppNotificationsApi.markUnread(3), e: 'PUT /api/notifications/in-app/3/unread' }, + { n: 'inApp.markAllRead', r: () => inAppNotificationsApi.markAllRead(), e: 'PUT /api/notifications/in-app/read-all' }, + { n: 'inApp.delete', r: () => inAppNotificationsApi.delete(3), e: 'DELETE /api/notifications/in-app/3' }, + { n: 'inApp.deleteAll', r: () => inAppNotificationsApi.deleteAll(), e: 'DELETE /api/notifications/in-app/all' }, + { n: 'inApp.respond', r: () => inAppNotificationsApi.respond(3, 'positive'), e: 'POST /api/notifications/in-app/3/respond' }, + ]) + }) +}) + +describe('client > request payloads', () => { + it('FE-APISURF-022: reorder helpers wrap their ids in the contract field', async () => { + expect((await traceOne(() => daysApi.reorder(1, [3, 1, 2]))).body).toEqual({ orderedIds: [3, 1, 2] }) + expect((await traceOne(() => packingApi.reorder(1, [2, 1]))).body).toEqual({ orderedIds: [2, 1] }) + expect((await traceOne(() => todoApi.reorder(1, [9]))).body).toEqual({ orderedIds: [9] }) + expect((await traceOne(() => budgetApi.reorderItems(1, [4, 5]))).body).toEqual({ orderedIds: [4, 5] }) + expect((await traceOne(() => budgetApi.reorderCategories(1, ['Food', 'Fun']))).body) + .toEqual({ orderedCategories: ['Food', 'Fun'] }) + expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) + }) + + it('FE-APISURF-023: user-id collections are sent as user_ids', async () => { + expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] }) + expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] }) + expect((await traceOne(() => packingApi.setBagMembers(1, 2, [6]))).body).toEqual({ user_ids: [6] }) + expect((await traceOne(() => reservationsApi.setTravelers(1, 2, [4, 6]))).body).toEqual({ user_ids: [4, 6] }) + }) + + it('FE-APISURF-024: single-value helpers wrap their argument in the documented key', async () => { + expect((await traceOne(() => authApi.updateMapsKey(null))).body).toEqual({ maps_api_key: null }) + expect((await traceOne(() => tripsApi.addMember(1, 'bob@x.test'))).body).toEqual({ identifier: 'bob@x.test' }) + expect((await traceOne(() => tripsApi.transferOwnership(1, 9))).body).toEqual({ newOwnerId: 9 }) + expect((await traceOne(() => tripsApi.createGuest(1, 'Anna'))).body).toEqual({ name: 'Anna' }) + expect((await traceOne(() => daysApi.updateTransport(1, 2, 'walk'))).body).toEqual({ transport_mode: 'walk' }) + expect((await traceOne(() => assignmentsApi.updateTransport(1, 7, null))).body).toEqual({ transport_mode: null }) + expect((await traceOne(() => collabApi.votePoll(1, 2, 3))).body).toEqual({ option_index: 3 }) + expect((await traceOne(() => collabApi.reactMessage(1, 2, 'πŸŽ‰'))).body).toEqual({ emoji: 'πŸŽ‰' }) + expect((await traceOne(() => settingsApi.set('theme', 'dark'))).body).toEqual({ key: 'theme', value: 'dark' }) + expect((await traceOne(() => settingsApi.setBulk({ a: 1 }))).body).toEqual({ settings: { a: 1 } }) + expect((await traceOne(() => budgetApi.togglePaid(1, 2, 4, false))).body).toEqual({ paid: false }) + expect((await traceOne(() => adminApi.updateBagTracking(true))).body).toEqual({ enabled: true }) + expect((await traceOne(() => adminApi.updatePermissions({ edit: 'owner' }))).body) + .toEqual({ permissions: { edit: 'owner' } }) + expect((await traceOne(() => pluginsApi.saveUserSettings('koffi', { k: 'v' }))).body) + .toEqual({ config: { k: 'v' } }) + }) + + it('FE-APISURF-025: tripsApi.archive/unarchive send the is_archived flag', async () => { + expect((await traceOne(() => tripsApi.archive(3))).body).toEqual({ is_archived: true }) + expect((await traceOne(() => tripsApi.unarchive(3))).body).toEqual({ is_archived: false }) + }) + + it('FE-APISURF-026: placesApi bulk operations merge ids with the patch', async () => { + expect((await traceOne(() => placesApi.bulkDelete(1, [5, 6]))).body).toEqual({ ids: [5, 6] }) + expect((await traceOne(() => placesApi.bulkUpdate(1, [5], { category_id: null }))).body) + .toEqual({ ids: [5], category_id: null }) + }) + + it('FE-APISURF-027: placesApi.rate deletes on null and PUTs the value otherwise', async () => { + const cleared = await traceOne(() => placesApi.rate(1, 5, null)) + expect(cleared.method).toBe('DELETE') + expect(cleared.url).toBe('/api/trips/1/places/5/rating') + + const set = await traceOne(() => placesApi.rate(1, 5, 4)) + expect(set.method).toBe('PUT') + expect(set.url).toBe('/api/trips/1/places/5/rating') + expect(set.body).toEqual({ rating: 4 }) + }) + + it('FE-APISURF-028: airtrailApi.import only sends connections when there are any', async () => { + expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2']))).body).toEqual({ flightIds: ['f1', 'f2'] }) + expect((await traceOne(() => airtrailApi.import(1, ['f1'], []))).body).toEqual({ flightIds: ['f1'] }) + expect((await traceOne(() => airtrailApi.import(1, ['f1', 'f2'], [['f1', 'f2']]))).body) + .toEqual({ flightIds: ['f1', 'f2'], connections: [['f1', 'f2']] }) + }) + + it('FE-APISURF-029: journeyApi provider-photo calls omit optional passphrase and media types', async () => { + expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'] }) + expect((await traceOne(() => journeyApi.addProviderPhotosToGallery(2, 'immich', ['a1'], 'secret', ['video']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], passphrase: 'secret', media_types: ['video'] }) + expect((await traceOne(() => journeyApi.addProviderPhoto(9, 'immich', 'a1', 'cap', 'secret'))).body) + .toEqual({ provider: 'immich', asset_id: 'a1', caption: 'cap', passphrase: 'secret' }) + expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap'))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap' }) + expect((await traceOne(() => journeyApi.addProviderPhotos(9, 'immich', ['a1'], 'cap', 'secret', ['image', 'video']))).body) + .toEqual({ provider: 'immich', asset_ids: ['a1'], caption: 'cap', passphrase: 'secret', media_types: ['image', 'video'] }) + }) + + it('FE-APISURF-030: adminApi.pluginActivate only sends consent when granted', async () => { + expect((await traceOne(() => adminApi.pluginActivate('koffi'))).body).toEqual({}) + expect((await traceOne(() => adminApi.pluginActivate('koffi', true))).body).toEqual({ consent: true }) + }) + + it('FE-APISURF-031: adminApi.pluginInstall spreads its options next to the id', async () => { + expect((await traceOne(() => adminApi.pluginInstall('koffi'))).body).toEqual({ id: 'koffi' }) + expect((await traceOne(() => adminApi.pluginInstall('koffi', { version: '2.0.0', withDependencies: true }))).body) + .toEqual({ id: 'koffi', version: '2.0.0', withDependencies: true }) + }) + + it('FE-APISURF-032: tripInviteApi.createLink normalises a missing expiry to null', async () => { + expect((await traceOne(() => tripInviteApi.createLink(1))).body).toEqual({ expires_in_days: null }) + expect((await traceOne(() => tripInviteApi.createLink(1, 14))).body).toEqual({ expires_in_days: 14 }) + }) + + it('FE-APISURF-033: tripsApi.copy and shareApi.createLink default to an empty body', async () => { + expect((await traceOne(() => tripsApi.copy(3))).body).toEqual({}) + expect((await traceOne(() => shareApi.createLink(1))).body).toEqual({}) + }) + + it('FE-APISURF-034: authApi.passkey.delete sends the password in the DELETE body', async () => { + const rec = await traceOne(() => authApi.passkey.delete(3, 'hunter2')) + expect(rec.method).toBe('DELETE') + expect(rec.body).toEqual({ password: 'hunter2' }) + }) +}) + +describe('client > query parameters', () => { + it('FE-APISURF-035: tripsApi.list forwards arbitrary filters as query params', async () => { + const rec = await traceOne(() => tripsApi.list({ archived: true, q: 'rome' })) + const qs = new URLSearchParams(rec.url.split('?')[1]) + expect(qs.get('archived')).toBe('true') + expect(qs.get('q')).toBe('rome') + }) + + it('FE-APISURF-036: filesApi.list only sets the trash flag when asked', async () => { + expect((await traceOne(() => filesApi.list(1))).url).toBe('/api/trips/1/files') + expect((await traceOne(() => filesApi.list(1, true))).url).toBe('/api/trips/1/files?trash=true') + }) + + it('FE-APISURF-037: budgetApi.settlement adds the base currency only when given', async () => { + expect((await traceOne(() => budgetApi.settlement(1))).url).toBe('/api/trips/1/budget/settlement') + expect((await traceOne(() => budgetApi.settlement(1, 'EUR'))).url).toBe('/api/trips/1/budget/settlement?base=EUR') + }) + + it('FE-APISURF-038: collabApi.getMessages appends the before cursor', async () => { + expect((await traceOne(() => collabApi.getMessages(1))).url).toBe('/api/trips/1/collab/messages') + expect((await traceOne(() => collabApi.getMessages(1, '2026-01-01'))).url) + .toBe('/api/trips/1/collab/messages?before=2026-01-01') + }) + + it('FE-APISURF-039: adminApi.pluginBrowse only sets refresh when forced', async () => { + expect((await traceOne(() => adminApi.pluginBrowse())).url).toBe('/api/admin/plugins/registry') + expect((await traceOne(() => adminApi.pluginBrowse(true))).url).toBe('/api/admin/plugins/registry?refresh=1') + }) + + it('FE-APISURF-040: adminApi.auditLog and llmLocalModels pass their params through', async () => { + const audit = await traceOne(() => adminApi.auditLog({ limit: 50, offset: 100 })) + expect(new URLSearchParams(audit.url.split('?')[1]).get('limit')).toBe('50') + expect(new URLSearchParams(audit.url.split('?')[1]).get('offset')).toBe('100') + + const models = await traceOne(() => adminApi.llmLocalModels('http://ollama:11434')) + expect(new URLSearchParams(models.url.split('?')[1]).get('baseUrl')).toBe('http://ollama:11434') + }) + + it('FE-APISURF-041: mapsApi flattens the POI bbox into the query string', async () => { + const rec = await traceOne(() => mapsApi.pois('cafe', { south: 41.8, west: 12.4, north: 42.0, east: 12.6 }, 'de')) + const qs = new URLSearchParams(rec.url.split('?')[1]) + expect(qs.get('category')).toBe('cafe') + expect(qs.get('south')).toBe('41.8') + expect(qs.get('west')).toBe('12.4') + expect(qs.get('north')).toBe('42') + expect(qs.get('east')).toBe('12.6') + expect(qs.get('lang')).toBe('de') + }) + + it('FE-APISURF-042: weatherApi sends lat/lng plus the date or language', async () => { + const forecast = await traceOne(() => weatherApi.get(41.9, 12.5, '2026-06-01')) + const fq = new URLSearchParams(forecast.url.split('?')[1]) + expect([fq.get('lat'), fq.get('lng'), fq.get('date')]).toEqual(['41.9', '12.5', '2026-06-01']) + + const current = await traceOne(() => weatherApi.getCurrent(41.9, 12.5, 'de')) + expect(new URLSearchParams(current.url.split('?')[1]).get('lang')).toBe('de') + }) + + it('FE-APISURF-043: pluginsApi joins trip ids and defaults the activity limit', async () => { + expect((await traceOne(() => pluginsApi.tripCardContributions([1, 2, 3]))).url) + .toBe('/api/trip-card-contributions?tripIds=1,2,3') + expect((await traceOne(() => pluginsApi.myActivity())).url).toBe('/api/plugin-activity?limit=200') + expect((await traceOne(() => pluginsApi.myActivity(5))).url).toBe('/api/plugin-activity?limit=5') + }) + + it('FE-APISURF-044: packing/todo category assignees encode the category name', async () => { + const packing = await traceOne(() => packingApi.setCategoryAssignees(1, 'Rain gear/Wet', [4])) + expect(packing.url).toBe('/api/trips/1/packing/category-assignees/Rain%20gear%2FWet') + expect(packing.body).toEqual({ user_ids: [4] }) + + const todo = await traceOne(() => todoApi.setCategoryAssignees(1, 'Before & after', [5])) + expect(todo.url).toBe('/api/trips/1/todo/category-assignees/Before%20%26%20after') + expect(todo.body).toEqual({ user_ids: [5] }) + }) + + it('FE-APISURF-045: collabApi.linkPreview URL-encodes the previewed link', async () => { + const rec = await traceOne(() => collabApi.linkPreview(1, 'https://x.test/a?b=1&c=2')) + expect(rec.url).toBe('/api/trips/1/collab/link-preview?url=https%3A%2F%2Fx.test%2Fa%3Fb%3D1%26c%3D2') + }) +}) + +describe('client > multipart uploads', () => { + // jsdom FormData bodies deadlock inside MSW, so uploads are asserted at the + // axios boundary instead (same approach as tests/integration/api/client.test.ts). + function spyPost() { + return vi.spyOn(apiClient, 'post') + .mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + } + + it('FE-APISURF-046: every upload opts out of the 8s global timeout', async () => { + const post = spyPost() + const fd = new FormData() + + await authApi.uploadAvatar(fd) + await tripsApi.uploadCover(3, fd) + await filesApi.upload(1, fd) + await journeyApi.uploadPhotos(9, fd) + await journeyApi.uploadGalleryPhotos(2, fd) + await journeyApi.uploadGalleryVideo(2, fd) + await journeyApi.uploadCover(2, fd) + await collabApi.uploadNoteFile(1, 2, fd) + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/auth/avatar', + '/trips/3/cover', + '/trips/1/files', + '/journeys/entries/9/photos', + '/journeys/2/gallery/photos', + '/journeys/2/gallery/video', + '/journeys/2/cover', + '/trips/1/collab/notes/2/files', + ]) + for (const call of post.mock.calls) { + expect(call[1]).toBeInstanceOf(FormData) + expect(call[2]).toMatchObject({ timeout: 0 }) + expect((call[2] as { headers: Record }).headers['Content-Type']).toBe('multipart/form-data') + } + }) + + it('FE-APISURF-047: postMultipart forwards progress, abort signal and idempotency key', async () => { + const post = spyPost() + const onUploadProgress = vi.fn((_e: unknown) => {}) + const controller = new AbortController() + + await filesApi.upload(1, new FormData(), { + onUploadProgress, + signal: controller.signal, + idempotencyKey: 'fixed-key', + }) + + const config = post.mock.calls[0][2] as { + headers: Record + onUploadProgress?: unknown + signal?: AbortSignal + timeout: number + } + expect(config.headers['X-Idempotency-Key']).toBe('fixed-key') + expect(config.onUploadProgress).toBe(onUploadProgress) + expect(config.signal).toBe(controller.signal) + expect(config.timeout).toBe(0) + }) + + it('FE-APISURF-048: placesApi.uploadImage posts the file under the image field', async () => { + const post = spyPost() + const file = new File(['bytes'], 'shot.jpg', { type: 'image/jpeg' }) + + await placesApi.uploadImage(1, 5, file) + + expect(post.mock.calls[0][0]).toBe('/trips/1/places/5/image') + const fd = post.mock.calls[0][1] as FormData + expect((fd.get('image') as File).name).toBe('shot.jpg') + }) + + it('FE-APISURF-049: placesApi.importGpx only appends the flags it was given', async () => { + const post = spyPost() + const file = new File([''], 'track.gpx') + + await placesApi.importGpx(1, file) + expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/gpx') + const bare = post.mock.calls[0][1] as FormData + expect(bare.get('importWaypoints')).toBeNull() + expect(bare.get('importRoutes')).toBeNull() + expect(bare.get('importTracks')).toBeNull() + + await placesApi.importGpx(1, file, { waypoints: true, routes: false, tracks: true }) + const flagged = post.mock.calls[1][1] as FormData + expect(flagged.get('importWaypoints')).toBe('true') + expect(flagged.get('importRoutes')).toBe('false') + expect(flagged.get('importTracks')).toBe('true') + }) + + it('FE-APISURF-050: placesApi.importMapFile appends the point/path flags', async () => { + const post = spyPost() + const file = new File(['{}'], 'map.kml') + + await placesApi.importMapFile(1, file) + expect(post.mock.calls[0][0]).toBe('/trips/1/places/import/map') + expect((post.mock.calls[0][1] as FormData).get('importPoints')).toBeNull() + + await placesApi.importMapFile(1, file, { points: true, paths: false }) + const flagged = post.mock.calls[1][1] as FormData + expect(flagged.get('importPoints')).toBe('true') + expect(flagged.get('importPaths')).toBe('false') + }) + + it('FE-APISURF-051: booking import posts every file plus the extraction mode', async () => { + const post = spyPost() + const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')] + + await reservationsApi.importBookingPreview(1, files, 'force-ai') + expect(post.mock.calls[0][0]).toBe('/trips/1/reservations/import/booking') + const preview = post.mock.calls[0][1] as FormData + expect(preview.getAll('files')).toHaveLength(2) + expect(preview.get('mode')).toBe('force-ai') + + await reservationsApi.importBookingAsync(1, files) + expect(post.mock.calls[1][0]).toBe('/trips/1/reservations/import/booking/async') + expect((post.mock.calls[1][1] as FormData).get('mode')).toBe('no-ai') + }) + + it('FE-APISURF-052: adminApi.pluginUpload and backupApi.uploadRestore name their form fields', async () => { + const post = spyPost() + + await adminApi.pluginUpload(new File(['zip'], 'plugin.zip')) + expect(post.mock.calls[0][0]).toBe('/admin/plugins/upload') + expect(((post.mock.calls[0][1] as FormData).get('file') as File).name).toBe('plugin.zip') + + await backupApi.uploadRestore(new File(['zip'], 'backup.zip')) + expect(post.mock.calls[1][0]).toBe('/backup/upload-restore') + expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip') + }) +}) diff --git a/client/src/api/collections.test.ts b/client/src/api/collections.test.ts new file mode 100644 index 000000000..8bb4875e4 --- /dev/null +++ b/client/src/api/collections.test.ts @@ -0,0 +1,342 @@ +// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032 +// +// The Collections addon wrapper is thin, but every method encodes a URL, a verb and a +// request-body shape that the server contract depends on. These tests drive each method +// through MSW and pin the method + path + payload, plus the unwrapping of `r.data`. +import { describe, it, expect, beforeEach } from 'vitest' +import { http, HttpResponse, type JsonBodyType } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { collectionsApi } from './collections' +import type { Collection, CollectionLabel, CollectionPlace } from '@trek/shared' + +const BASE = '/api/addons/collections' + +const collection: Collection = { id: 1, owner_id: 1, name: 'Tokyo', place_count: 2, is_owner: true } +const place: CollectionPlace = { id: 10, collection_id: 1, name: 'Shibuya Crossing', status: 'want' } +const label: CollectionLabel = { id: 3, collection_id: 1, name: 'Food', color: '#ef4444' } + +let requestUrl = '' +let requestBody: unknown + +beforeEach(() => { + requestUrl = '' + requestBody = undefined +}) + +/** Records url + parsed JSON body of the intercepted request, then answers with `data`. */ +function record(data: T) { + return async ({ request }: { request: Request }) => { + requestUrl = request.url + const text = await request.text() + if (text) { + try { + requestBody = JSON.parse(text) + } catch { + requestBody = text + } + } + return HttpResponse.json(data) + } +} + +describe('collectionsApi', () => { + it('FE-API-COLLECTIONS-001: list() unwraps the collections + incomingInvites envelope', async () => { + server.use(http.get(BASE, record({ collections: [collection], incomingInvites: [] }))) + + const res = await collectionsApi.list() + + expect(res.collections).toEqual([collection]) + expect(res.incomingInvites).toEqual([]) + }) + + it('FE-API-COLLECTIONS-002: get() requests the list by id', async () => { + server.use(http.get(`${BASE}/:id`, record({ collection, places: [place] }))) + + const res = await collectionsApi.get(1) + + expect(requestUrl).toContain(`${BASE}/1`) + expect(res.places).toEqual([place]) + expect(res.collection.name).toBe('Tokyo') + }) + + it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => { + server.use(http.post(BASE, record({ collection }))) + + const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' }) + + expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' }) + expect(res.collection.id).toBe(1) + }) + + it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => { + server.use(http.patch(`${BASE}/:id`, record({ collection }))) + + const res = await collectionsApi.update(1, { name: 'Tokyo 2026' }) + + expect(requestUrl).toContain(`${BASE}/1`) + expect(requestBody).toEqual({ name: 'Tokyo 2026' }) + expect(res.collection).toEqual(collection) + }) + + it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => { + server.use(http.post(`${BASE}/:id/cover`, record(collection))) + + const fd = new FormData() + fd.append('cover', new File(['x'], 'cover.jpg')) + const res = await collectionsApi.uploadCover(1, fd) + + expect(requestUrl).toContain(`${BASE}/1/cover`) + expect(res).toEqual(collection) + }) + + it('FE-API-COLLECTIONS-006: remove() deletes the list', async () => { + server.use(http.delete(`${BASE}/:id`, record({ success: true }))) + + const res = await collectionsApi.remove(4) + + expect(requestUrl).toContain(`${BASE}/4`) + expect(res).toEqual({ success: true }) + }) + + it('FE-API-COLLECTIONS-007: reorder() posts the ordered ids', async () => { + server.use(http.post(`${BASE}/reorder`, record({ success: true }))) + + await collectionsApi.reorder([3, 1, 2]) + + expect(requestBody).toEqual({ orderedIds: [3, 1, 2] }) + }) + + it('FE-API-COLLECTIONS-008: savePlace() posts the place payload', async () => { + server.use(http.post(`${BASE}/places`, record({ place }))) + + const res = await collectionsApi.savePlace({ collection_id: 1, name: 'Shibuya Crossing', force: true }) + + expect(requestBody).toEqual({ collection_id: 1, name: 'Shibuya Crossing', force: true }) + expect(res.place).toEqual(place) + }) + + it('FE-API-COLLECTIONS-009: saveFromTrip() posts the provenance-only payload', async () => { + server.use(http.post(`${BASE}/places/from-trip`, record({ duplicate: true, duplicateOf: { id: 9, name: 'Shibuya' } }))) + + const res = await collectionsApi.saveFromTrip({ collection_id: 1, source_trip_id: 7, source_place_id: 42 }) + + expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_id: 42 }) + expect(res.duplicate).toBe(true) + }) + + it('FE-API-COLLECTIONS-010: saveFromTripMany() maps its arguments onto the bulk payload', async () => { + server.use(http.post(`${BASE}/places/from-trip-many`, record({ copied: 2, skipped: [] }))) + + const res = await collectionsApi.saveFromTripMany(1, 7, [11, 12], true) + + expect(requestBody).toEqual({ collection_id: 1, source_trip_id: 7, source_place_ids: [11, 12], force: true }) + expect(res.copied).toBe(2) + }) + + it('FE-API-COLLECTIONS-011: updatePlace() patches the place and returns it unwrapped', async () => { + server.use(http.patch(`${BASE}/places/:pid`, record({ ...place, notes: 'busy at night' }))) + + const res = await collectionsApi.updatePlace(10, { notes: 'busy at night' }) + + expect(requestUrl).toContain(`${BASE}/places/10`) + expect(requestBody).toEqual({ notes: 'busy at night' }) + expect(res.notes).toBe('busy at night') + }) + + it('FE-API-COLLECTIONS-012: uploadPlaceImage() posts multipart to the place image endpoint', async () => { + server.use(http.post(`${BASE}/places/:pid/image`, record({ ...place, image_url: '/uploads/p.jpg' }))) + + const fd = new FormData() + fd.append('image', new File(['x'], 'p.jpg')) + const res = await collectionsApi.uploadPlaceImage(10, fd) + + expect(requestUrl).toContain(`${BASE}/places/10/image`) + expect(res.image_url).toBe('/uploads/p.jpg') + }) + + it('FE-API-COLLECTIONS-013: setStatus() posts the status', async () => { + server.use(http.post(`${BASE}/places/:pid/status`, record({ ...place, status: 'visited' }))) + + const res = await collectionsApi.setStatus(10, 'visited') + + expect(requestUrl).toContain(`${BASE}/places/10/status`) + expect(requestBody).toEqual({ status: 'visited' }) + expect(res.status).toBe('visited') + }) + + it('FE-API-COLLECTIONS-014: ratePlace() PUTs a numeric rating', async () => { + server.use(http.put(`${BASE}/places/:pid/rating`, record({ ...place, rating_avg: 4 }))) + + const res = await collectionsApi.ratePlace(10, 4) + + expect(requestUrl).toContain(`${BASE}/places/10/rating`) + expect(requestBody).toEqual({ rating: 4 }) + expect(res.rating_avg).toBe(4) + }) + + it('FE-API-COLLECTIONS-015: ratePlace(null) DELETEs the rating instead', async () => { + let deleted = false + server.use( + http.put(`${BASE}/places/:pid/rating`, () => HttpResponse.json({ error: 'should not be called' }, { status: 500 })), + http.delete(`${BASE}/places/:pid/rating`, () => { + deleted = true + return HttpResponse.json({ ...place, rating_avg: null }) + }), + ) + + const res = await collectionsApi.ratePlace(10, null) + + expect(deleted).toBe(true) + expect(res.rating_avg).toBeNull() + }) + + it('FE-API-COLLECTIONS-016: deletePlace() deletes the saved place', async () => { + server.use(http.delete(`${BASE}/places/:pid`, record({ success: true }))) + + await collectionsApi.deletePlace(10) + + expect(requestUrl).toContain(`${BASE}/places/10`) + }) + + it('FE-API-COLLECTIONS-017: deleteMany() posts the id list', async () => { + server.use(http.post(`${BASE}/places/delete-many`, record({ deleted: 2 }))) + + const res = await collectionsApi.deleteMany([10, 11]) + + expect(requestBody).toEqual({ ids: [10, 11] }) + expect(res).toEqual({ deleted: 2 }) + }) + + it('FE-API-COLLECTIONS-018: copyToTrip() posts the copy payload and returns the dedup report', async () => { + server.use(http.post(`${BASE}/copy-to-trip`, record({ copied: 1, skipped: [{ id: 11, name: 'Shibuya' }] }))) + + const res = await collectionsApi.copyToTrip({ trip_id: 7, place_ids: [10, 11] }) + + expect(requestBody).toEqual({ trip_id: 7, place_ids: [10, 11] }) + expect(res.copied).toBe(1) + expect(res.skipped).toEqual([{ id: 11, name: 'Shibuya' }]) + }) + + it('FE-API-COLLECTIONS-019: membership() sends the lookup as query params', async () => { + server.use(http.get(`${BASE}/membership`, record({ saved: true, lists: [{ collection_id: 1, name: 'Tokyo', place_id: 10 }] }))) + + const res = await collectionsApi.membership({ google_place_id: 'g1', lat: 35.6, lng: 139.7 }) + const params = new URL(requestUrl).searchParams + + expect(params.get('google_place_id')).toBe('g1') + expect(params.get('lat')).toBe('35.6') + expect(params.get('lng')).toBe('139.7') + expect(res.saved).toBe(true) + }) + + it('FE-API-COLLECTIONS-020: invite() posts collection_id, user_id and role', async () => { + server.use(http.post(`${BASE}/invite`, record({ success: true }))) + + await collectionsApi.invite(1, 5, 'admin') + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'admin' }) + }) + + it('FE-API-COLLECTIONS-021: setMemberRole() posts the new role', async () => { + server.use(http.post(`${BASE}/members/role`, record({ success: true }))) + + await collectionsApi.setMemberRole(1, 5, 'viewer') + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5, role: 'viewer' }) + }) + + it('FE-API-COLLECTIONS-022: acceptInvite() posts only the collection id', async () => { + server.use(http.post(`${BASE}/invite/accept`, record({ success: true }))) + + await collectionsApi.acceptInvite(1) + + expect(requestBody).toEqual({ collection_id: 1 }) + }) + + it('FE-API-COLLECTIONS-023: declineInvite() posts only the collection id', async () => { + server.use(http.post(`${BASE}/invite/decline`, record({ success: true }))) + + await collectionsApi.declineInvite(2) + + expect(requestBody).toEqual({ collection_id: 2 }) + }) + + it('FE-API-COLLECTIONS-024: cancelInvite() posts collection_id and user_id', async () => { + server.use(http.post(`${BASE}/invite/cancel`, record({ success: true }))) + + await collectionsApi.cancelInvite(1, 5) + + expect(requestBody).toEqual({ collection_id: 1, user_id: 5 }) + }) + + it('FE-API-COLLECTIONS-025: leave() posts the collection id', async () => { + server.use(http.post(`${BASE}/leave`, record({ success: true }))) + + await collectionsApi.leave(3) + + expect(requestBody).toEqual({ collection_id: 3 }) + }) + + it('FE-API-COLLECTIONS-026: removeMember() posts collection_id and user_id', async () => { + server.use(http.post(`${BASE}/members/remove`, record({ success: true }))) + + await collectionsApi.removeMember(1, 9) + + expect(requestBody).toEqual({ collection_id: 1, user_id: 9 }) + }) + + it('FE-API-COLLECTIONS-027: availableUsers() reads the invitable users for a list', async () => { + server.use(http.get(`${BASE}/:id/available-users`, record({ users: [{ id: 5, username: 'bob' }] }))) + + const res = await collectionsApi.availableUsers(1) + + expect(requestUrl).toContain(`${BASE}/1/available-users`) + expect(res.users).toEqual([{ id: 5, username: 'bob' }]) + }) + + it('FE-API-COLLECTIONS-028: createLabel() posts collection_id, name and color', async () => { + server.use(http.post(`${BASE}/labels`, record(label))) + + const res = await collectionsApi.createLabel(1, 'Food', '#ef4444') + + expect(requestBody).toEqual({ collection_id: 1, name: 'Food', color: '#ef4444' }) + expect(res).toEqual(label) + }) + + it('FE-API-COLLECTIONS-029: updateLabel() patches the label by id', async () => { + server.use(http.patch(`${BASE}/labels/:id`, record({ ...label, name: 'Eats' }))) + + const res = await collectionsApi.updateLabel(3, { name: 'Eats' }) + + expect(requestUrl).toContain(`${BASE}/labels/3`) + expect(requestBody).toEqual({ name: 'Eats' }) + expect(res.name).toBe('Eats') + }) + + it('FE-API-COLLECTIONS-030: deleteLabel() deletes the label by id', async () => { + server.use(http.delete(`${BASE}/labels/:id`, record({ success: true }))) + + await collectionsApi.deleteLabel(3) + + expect(requestUrl).toContain(`${BASE}/labels/3`) + }) + + it('FE-API-COLLECTIONS-031: assignLabels() posts label_ids and place_ids', async () => { + server.use(http.post(`${BASE}/labels/assign`, record({ changed: 2 }))) + + const res = await collectionsApi.assignLabels([3], [10, 11]) + + expect(requestBody).toEqual({ label_ids: [3], place_ids: [10, 11] }) + expect(res.changed).toBe(2) + }) + + it('FE-API-COLLECTIONS-032: unassignLabels() posts to the unassign endpoint', async () => { + server.use(http.post(`${BASE}/labels/unassign`, record({ changed: 1 }))) + + const res = await collectionsApi.unassignLabels([3], [10]) + + expect(requestUrl).toContain(`${BASE}/labels/unassign`) + expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] }) + expect(res.changed).toBe(1) + }) +}) diff --git a/client/src/api/websocket.extra.test.ts b/client/src/api/websocket.extra.test.ts new file mode 100644 index 000000000..3522c6a87 --- /dev/null +++ b/client/src/api/websocket.extra.test.ts @@ -0,0 +1,267 @@ +// vi.unmock must run before the module is imported (tests/setup.ts mocks it globally) +vi.unmock('./websocket') + +// FE-WSCORE-001 to FE-WSCORE-014 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { http, HttpResponse } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { + connect, disconnect, joinTrip, leaveTrip, getActiveTrips, + setRefetchCallback, setPreReconnectHook, +} from './websocket' + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + static instances: MockWebSocket[] = [] + + readyState: number = MockWebSocket.OPEN + send = vi.fn((_data: string) => {}) + close = vi.fn(() => {}) + onopen: (() => void) | null = null + onmessage: ((event: { data: string }) => void) | null = null + onclose: (() => void) | null = null + onerror: (() => void) | null = null + + constructor(public url: string) { + MockWebSocket.instances.push(this) + } +} + +function lastSocket(): MockWebSocket { + return MockWebSocket.instances[MockWebSocket.instances.length - 1] +} + +const realLocation = window.location + +beforeEach(() => { + vi.useFakeTimers() + MockWebSocket.instances = [] + Object.defineProperty(globalThis, 'WebSocket', { + writable: true, configurable: true, value: MockWebSocket, + }) + server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'ws-tok' }))) +}) + +afterEach(() => { + disconnect() + setRefetchCallback(null) + setPreReconnectHook(null) + vi.useRealTimers() + vi.restoreAllMocks() + Object.defineProperty(window, 'location', { writable: true, configurable: true, value: realLocation }) +}) + +/** connect() + settle the token fetch so a socket exists. */ +async function openSocket(): Promise { + connect() + await vi.advanceTimersByTimeAsync(0) + return lastSocket() +} + +describe('websocket > active trips', () => { + it('FE-WSCORE-001: getActiveTrips lists the joined trips as strings', async () => { + expect(getActiveTrips()).toEqual([]) + + joinTrip(42) + joinTrip('7') + expect(getActiveTrips()).toEqual(['42', '7']) + + disconnect() + expect(getActiveTrips()).toEqual([]) + }) + + it('FE-WSCORE-013: join/leave still bookkeep while no socket is open', () => { + joinTrip(5) + expect(getActiveTrips()).toEqual(['5']) + + leaveTrip(5) + expect(getActiveTrips()).toEqual([]) + }) + + it('FE-WSCORE-014: a trip joined before onopen is not re-sent while the socket is closing', async () => { + joinTrip(11) + const sock = await openSocket() + sock.readyState = MockWebSocket.CLOSING + + sock.onopen!() + + expect(sock.send).not.toHaveBeenCalled() + }) +}) + +describe('websocket > reconnect refetch hook', () => { + it('FE-WSCORE-002: the pre-reconnect hook is awaited before the refetch runs', async () => { + const order: string[] = [] + setPreReconnectHook(async () => { order.push('flush') }) + setRefetchCallback(() => { order.push('refetch') }) + + joinTrip(3) + const sock = await openSocket() + sock.onopen!() + await vi.advanceTimersByTimeAsync(0) + + expect(order).toEqual(['flush', 'refetch']) + }) + + it('FE-WSCORE-003: a rejecting pre-reconnect hook still lets the refetch run', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const refetch = vi.fn((_tripId: string) => {}) + setPreReconnectHook(async () => { throw new Error('queue flush failed') }) + setRefetchCallback(refetch) + + joinTrip(3) + const sock = await openSocket() + sock.onopen!() + await vi.advanceTimersByTimeAsync(0) + + expect(refetch).toHaveBeenCalledWith('3') + expect(consoleError).toHaveBeenCalled() + }) + + it('FE-WSCORE-004: a throwing refetch callback is logged, not propagated', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + setRefetchCallback(() => { throw new Error('store blew up') }) + + joinTrip(3) + const sock = await openSocket() + + expect(() => sock.onopen!()).not.toThrow() + expect(consoleError).toHaveBeenCalledWith( + 'Failed to refetch trip data on reconnect:', + expect.any(Error), + ) + }) + + it('FE-WSCORE-005: with no joined trips onopen sends nothing and skips the refetch', async () => { + const refetch = vi.fn((_tripId: string) => {}) + setRefetchCallback(refetch) + + const sock = await openSocket() + sock.onopen!() + + expect(sock.send).not.toHaveBeenCalled() + expect(refetch).not.toHaveBeenCalled() + }) +}) + +describe('websocket > connection lifecycle', () => { + it('FE-WSCORE-006: connect() is a no-op while a socket is still CONNECTING', async () => { + const sock = await openSocket() + sock.readyState = MockWebSocket.CONNECTING + + connect() + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-007: connect() cancels a pending reconnect timer', async () => { + server.use(http.post('/api/auth/ws-token', () => new HttpResponse(null, { status: 503 }))) + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + + // A retry is now armed; connect() must clear it and dial immediately. + server.use(http.post('/api/auth/ws-token', () => HttpResponse.json({ token: 'fresh' }))) + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(1) + + // The cancelled timer must not fire a second dial afterwards. + await vi.advanceTimersByTimeAsync(5000) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-008: a duplicate close does not stack a second timer or skip a backoff step', async () => { + const sock = await openSocket() + + // Every further token fetch fails, so each retry attempt is countable. + let attempts = 0 + server.use(http.post('/api/auth/ws-token', () => { + attempts++ + return new HttpResponse(null, { status: 503 }) + })) + + // A browser can deliver close twice (after onerror); the second must be ignored. + sock.onclose!() + sock.onclose!() + + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + expect(attempts, 'first retry fires after the 1s delay').toBe(1) + + // Backoff advanced once (1s β†’ 2s), not twice, so the next retry lands at 2s. + await vi.advanceTimersByTimeAsync(2001) + await vi.advanceTimersByTimeAsync(0) + expect(attempts, 'second retry fires after the doubled 2s delay').toBe(2) + + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-009: a failing ws-token fetch schedules a retry instead of throwing', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('offline')) + + connect() + await vi.advanceTimersByTimeAsync(0) + expect(MockWebSocket.instances).toHaveLength(0) + + vi.mocked(globalThis.fetch).mockResolvedValue( + new Response(JSON.stringify({ token: 'back-online' }), { + status: 200, headers: { 'Content-Type': 'application/json' }, + }), + ) + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(1) + expect(lastSocket().url).toContain('token=back-online') + }) + + it('FE-WSCORE-010: the socket URL uses ws:// on http and wss:// on https', async () => { + const httpSock = await openSocket() + expect(httpSock.url.startsWith('ws://')).toBe(true) + + disconnect() + MockWebSocket.instances = [] + Object.defineProperty(window, 'location', { + writable: true, configurable: true, + value: { + protocol: 'https:', + host: 'trip.example', + origin: 'https://trip.example', + href: 'https://trip.example/dashboard', + pathname: '/dashboard', + }, + }) + + const secure = await openSocket() + expect(secure.url).toBe('wss://trip.example/ws?token=ws-tok') + }) + + it('FE-WSCORE-011: disconnect() detaches onclose so no reconnect is armed', async () => { + const sock = await openSocket() + disconnect() + + expect(sock.onclose).toBeNull() + expect(sock.close).toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(5000) + expect(MockWebSocket.instances).toHaveLength(1) + }) + + it('FE-WSCORE-012: onerror is inert β€” the reconnect is driven by onclose', async () => { + const sock = await openSocket() + + expect(() => sock.onerror!()).not.toThrow() + expect(MockWebSocket.instances).toHaveLength(1) + + sock.onclose!() + await vi.advanceTimersByTimeAsync(1001) + await vi.advanceTimersByTimeAsync(0) + + expect(MockWebSocket.instances).toHaveLength(2) + }) +}) diff --git a/client/src/db/offlineDb.test.ts b/client/src/db/offlineDb.test.ts new file mode 100644 index 000000000..c0abb2dd6 --- /dev/null +++ b/client/src/db/offlineDb.test.ts @@ -0,0 +1,402 @@ +// FE-DB-OFFLINE-001 to FE-DB-OFFLINE-029 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import 'fake-indexeddb/auto' +import Dexie from 'dexie' +import { + offlineDb, + clearAll, + clearTripData, + reopenForUser, + reopenAnonymous, + deleteCurrentUserDb, + upsertTrip, + upsertDays, + upsertPlaces, + upsertPackingItems, + upsertTodoItems, + upsertBudgetItems, + upsertReservations, + upsertTripFiles, + upsertAccommodations, + upsertTripMembers, + upsertTags, + upsertCategories, + upsertSyncMeta, + getCachedBlob, + saveImportFiles, + getImportFiles, + deleteImportFiles, + enforceBlobBudget, + BLOB_CACHE_MAX_ENTRIES, + BLOB_CACHE_MAX_BYTES, +} from './offlineDb' +import type { BlobCacheEntry, QueuedMutation } from './offlineDb' +import type { Accommodation, TripMember } from '../types' +import { + buildTrip, + buildDay, + buildPlace, + buildPackingItem, + buildTodoItem, + buildBudgetItem, + buildReservation, + buildTripFile, + buildTag, + buildCategory, +} from '../../tests/helpers/factories' + +function blobEntry(url: string, cachedAt: number, bytes: number, tripId = 1): BlobCacheEntry { + return { url, tripId, blob: new Blob(['x'.repeat(bytes)]), bytes, mime: 'application/pdf', cachedAt } +} + +function queued(id: string, tripId: number, status: QueuedMutation['status']): QueuedMutation { + return { id, tripId, method: 'PUT', url: `/trips/${tripId}/places/1`, body: {}, createdAt: 1, status, attempts: 0, lastError: null } +} + +beforeEach(async () => { + await clearAll() +}) + +afterEach(async () => { + vi.restoreAllMocks() + await reopenAnonymous() +}) + +describe('offlineDb β€” bulk upsert helpers', () => { + it('FE-DB-OFFLINE-001: every entity helper writes into its own table', async () => { + await upsertTrip(buildTrip({ id: 1 })) + await upsertDays([buildDay({ id: 1, trip_id: 1 })]) + await upsertPlaces([buildPlace({ id: 1, trip_id: 1 })]) + await upsertPackingItems([buildPackingItem({ id: 1, trip_id: 1 })]) + await upsertTodoItems([buildTodoItem({ id: 1, trip_id: 1 })]) + await upsertBudgetItems([buildBudgetItem({ id: 1, trip_id: 1 })]) + await upsertReservations([buildReservation({ id: 1, trip_id: 1 })]) + await upsertTripFiles([buildTripFile({ id: 1, trip_id: 1 })]) + await upsertAccommodations([{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 2 } as Accommodation]) + await upsertTags([buildTag({ id: 1 })]) + await upsertCategories([buildCategory({ id: 1 })]) + + expect(await offlineDb.trips.count()).toBe(1) + expect(await offlineDb.days.count()).toBe(1) + expect(await offlineDb.places.count()).toBe(1) + expect(await offlineDb.packingItems.count()).toBe(1) + expect(await offlineDb.todoItems.count()).toBe(1) + expect(await offlineDb.budgetItems.count()).toBe(1) + expect(await offlineDb.reservations.count()).toBe(1) + expect(await offlineDb.tripFiles.count()).toBe(1) + expect(await offlineDb.accommodations.count()).toBe(1) + expect(await offlineDb.tags.count()).toBe(1) + expect(await offlineDb.categories.count()).toBe(1) + }) + + it('FE-DB-OFFLINE-002: upsertTripMembers stamps the tripId onto every member row', async () => { + const members = [ + { id: 5, username: 'ana', role: 'owner' }, + { id: 6, username: 'ben', role: 'member' }, + ] as unknown as TripMember[] + + await upsertTripMembers(42, members) + + const rows = await offlineDb.tripMembers.where('tripId').equals(42).toArray() + expect(rows.map(r => r.username).sort()).toEqual(['ana', 'ben']) + expect(rows.every(r => r.tripId === 42)).toBe(true) + }) + + it('FE-DB-OFFLINE-003: upsertSyncMeta overwrites the previous row for the same trip', async () => { + await upsertSyncMeta({ tripId: 1, lastSyncedAt: 100, status: 'idle', tilesBbox: null, filesCachedCount: 0 }) + await upsertSyncMeta({ tripId: 1, lastSyncedAt: 200, status: 'error', tilesBbox: [0, 0, 1, 1], filesCachedCount: 3 }) + + const meta = await offlineDb.syncMeta.get(1) + expect(meta).toMatchObject({ lastSyncedAt: 200, status: 'error', filesCachedCount: 3 }) + expect(await offlineDb.syncMeta.count()).toBe(1) + }) +}) + +describe('offlineDb β€” getCachedBlob', () => { + it('FE-DB-OFFLINE-004: returns null when the url was never cached', async () => { + expect(await getCachedBlob('/api/files/1/download')).toBeNull() + }) + + it('FE-DB-OFFLINE-005: reapplies the stored MIME when the persisted Blob lost its type', async () => { + await offlineDb.blobCache.put({ + url: '/a.csv', tripId: 1, blob: new Blob(['a,b']), bytes: 3, mime: 'text/csv', cachedAt: 1, + }) + + const blob = await getCachedBlob('/a.csv') + expect(blob).toBeInstanceOf(Blob) + expect(blob!.type).toBe('text/csv') + }) + + it('FE-DB-OFFLINE-006: falls back to octet-stream when neither the Blob nor the row has a type', async () => { + await offlineDb.blobCache.put({ + url: '/a.bin', tripId: 1, blob: new Blob(['a']), bytes: 1, mime: '', cachedAt: 1, + }) + + expect((await getCachedBlob('/a.bin'))!.type).toBe('application/octet-stream') + }) + + it('FE-DB-OFFLINE-007: a read error degrades to null instead of throwing', async () => { + vi.spyOn(offlineDb.blobCache, 'get').mockRejectedValue(new Error('db closed')) + expect(await getCachedBlob('/a.pdf')).toBeNull() + }) +}) + +describe('offlineDb β€” booking-import source files', () => { + it('FE-DB-OFFLINE-008: stores a job\'s files under [jobId+fileName] and rebuilds them as Files', async () => { + await saveImportFiles('job-1', [ + new File(['a'], 'ticket.pdf', { type: 'application/pdf' }), + new File(['b'], 'boarding.pdf', { type: 'application/pdf' }), + ]) + + expect(await offlineDb.importFiles.where('jobId').equals('job-1').count()).toBe(2) + + const files = await getImportFiles('job-1') + expect(files.map(f => f.name).sort()).toEqual(['boarding.pdf', 'ticket.pdf']) + expect(files.every(f => f instanceof File)).toBe(true) + }) + + it('FE-DB-OFFLINE-009: a stored Blob without a type is rebuilt as octet-stream', async () => { + await saveImportFiles('job-2', [new File(['x'], 'unknown')]) + expect((await getImportFiles('job-2'))[0].type).toBe('application/octet-stream') + }) + + it('FE-DB-OFFLINE-010: saving prunes source files from abandoned imports older than an hour', async () => { + await offlineDb.importFiles.put({ + jobId: 'stale', fileName: 'old.pdf', blob: new Blob(['old']), createdAt: Date.now() - 2 * 3600_000, + }) + + await saveImportFiles('fresh', [new File(['new'], 'new.pdf')]) + + expect(await getImportFiles('stale')).toEqual([]) + expect(await getImportFiles('fresh')).toHaveLength(1) + }) + + it('FE-DB-OFFLINE-011: deleteImportFiles drops only the given job', async () => { + await saveImportFiles('job-a', [new File(['a'], 'a.pdf')]) + await saveImportFiles('job-b', [new File(['b'], 'b.pdf')]) + + await deleteImportFiles('job-a') + + expect(await getImportFiles('job-a')).toEqual([]) + expect(await getImportFiles('job-b')).toHaveLength(1) + }) + + it('FE-DB-OFFLINE-012: all three helpers stay best-effort when Dexie throws', async () => { + vi.spyOn(offlineDb.importFiles, 'bulkPut').mockRejectedValue(new Error('quota')) + vi.spyOn(offlineDb.importFiles, 'where').mockImplementation(() => { throw new Error('db closed') }) + + await expect(saveImportFiles('job-c', [new File(['c'], 'c.pdf')])).resolves.toBeUndefined() + await expect(getImportFiles('job-c')).resolves.toEqual([]) + await expect(deleteImportFiles('job-c')).resolves.toBeUndefined() + }) +}) + +describe('offlineDb β€” blob cache budget', () => { + it('FE-DB-OFFLINE-013: exposes conservative defaults', () => { + expect(BLOB_CACHE_MAX_ENTRIES).toBe(200) + expect(BLOB_CACHE_MAX_BYTES).toBe(100 * 1024 * 1024) + }) + + it('FE-DB-OFFLINE-014: a cache within both budgets is left untouched', async () => { + await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 10), blobEntry('/2', 2, 10)]) + await enforceBlobBudget(5, 1000) + expect(await offlineDb.blobCache.count()).toBe(2) + }) + + it('FE-DB-OFFLINE-015: evicts oldest-first until the entry count fits', async () => { + await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 10), blobEntry('/2', 2, 10), blobEntry('/3', 3, 10)]) + + await enforceBlobBudget(1, 1000) + + expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/3']) + }) + + it('FE-DB-OFFLINE-016: evicts oldest-first until the byte budget fits', async () => { + await offlineDb.blobCache.bulkPut([blobEntry('/1', 1, 100), blobEntry('/2', 2, 100), blobEntry('/3', 3, 100)]) + + await enforceBlobBudget(100, 150) + + expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/3']) + }) + + it('FE-DB-OFFLINE-017: rows written before the bytes column are counted as zero', async () => { + await offlineDb.blobCache.bulkPut([ + { url: '/legacy', tripId: -1, blob: new Blob(['x']), mime: '', cachedAt: 1 } as unknown as BlobCacheEntry, + blobEntry('/2', 2, 100), + ]) + + await enforceBlobBudget(100, 100) + + expect(await offlineDb.blobCache.count()).toBe(2) + }) +}) + +describe('offlineDb β€” clearTripData', () => { + it('FE-DB-OFFLINE-018: drops the trip\'s read cache and leaves other trips alone', async () => { + await upsertTrip(buildTrip({ id: 1 })) + await upsertTrip(buildTrip({ id: 2 })) + await upsertDays([buildDay({ id: 1, trip_id: 1 }), buildDay({ id: 2, trip_id: 2 })]) + await upsertPlaces([buildPlace({ id: 1, trip_id: 1 }), buildPlace({ id: 2, trip_id: 2 })]) + await upsertPackingItems([buildPackingItem({ id: 1, trip_id: 1 })]) + await upsertTodoItems([buildTodoItem({ id: 1, trip_id: 1 })]) + await upsertBudgetItems([buildBudgetItem({ id: 1, trip_id: 1 })]) + await upsertReservations([buildReservation({ id: 1, trip_id: 1 })]) + await upsertTripFiles([buildTripFile({ id: 1, trip_id: 1 })]) + await upsertAccommodations([{ id: 1, trip_id: 1, start_day_id: 1, end_day_id: 2 } as Accommodation]) + await upsertTripMembers(1, [{ id: 9, username: 'ana', role: 'owner' } as unknown as TripMember]) + await upsertSyncMeta({ tripId: 1, lastSyncedAt: 1, status: 'idle', tilesBbox: null, filesCachedCount: 0 }) + await offlineDb.blobCache.put(blobEntry('/f1', 1, 10, 1)) + await offlineDb.blobCache.put(blobEntry('/f2', 2, 10, 2)) + + await clearTripData(1) + + expect(await offlineDb.trips.get(1)).toBeUndefined() + expect(await offlineDb.days.where('trip_id').equals(1).count()).toBe(0) + expect(await offlineDb.places.where('trip_id').equals(1).count()).toBe(0) + expect(await offlineDb.packingItems.count()).toBe(0) + expect(await offlineDb.todoItems.count()).toBe(0) + expect(await offlineDb.budgetItems.count()).toBe(0) + expect(await offlineDb.reservations.count()).toBe(0) + expect(await offlineDb.tripFiles.count()).toBe(0) + expect(await offlineDb.accommodations.count()).toBe(0) + expect(await offlineDb.tripMembers.count()).toBe(0) + expect(await offlineDb.syncMeta.get(1)).toBeUndefined() + + expect(await offlineDb.trips.get(2)).toBeDefined() + expect(await offlineDb.days.where('trip_id').equals(2).count()).toBe(1) + expect((await offlineDb.blobCache.toArray()).map(e => e.url)).toEqual(['/f2']) + }) + + it('FE-DB-OFFLINE-019: keeps unsynced work and only purges dead failed mutations', async () => { + await offlineDb.mutationQueue.bulkPut([ + queued('m-pending', 1, 'pending'), + queued('m-syncing', 1, 'syncing'), + queued('m-conflict', 1, 'conflict'), + queued('m-failed', 1, 'failed'), + queued('m-other-trip', 2, 'failed'), + ]) + + await clearTripData(1) + + const left = (await offlineDb.mutationQueue.toArray()).map(m => m.id).sort() + expect(left).toEqual(['m-conflict', 'm-other-trip', 'm-pending', 'm-syncing']) + }) +}) + +describe('offlineDb β€” per-user database scoping', () => { + it('FE-DB-OFFLINE-020: reopenForUser switches to the user-scoped database', async () => { + await reopenForUser(7) + expect(offlineDb.name).toBe('trek-offline-u7') + expect(offlineDb.isOpen()).toBe(true) + }) + + it('FE-DB-OFFLINE-021: one account cannot read another account\'s cached trips', async () => { + await reopenForUser(7) + await upsertTrip(buildTrip({ id: 500, title: 'Seven' })) + + await reopenForUser(8) + expect(await offlineDb.trips.get(500)).toBeUndefined() + + await reopenForUser(7) + expect((await offlineDb.trips.get(500))!.title).toBe('Seven') + }) + + it('FE-DB-OFFLINE-022: switching to the database already in use just reopens it', async () => { + await reopenForUser(7) + offlineDb.close() + + await reopenForUser(7) + expect(offlineDb.name).toBe('trek-offline-u7') + expect(offlineDb.isOpen()).toBe(true) + }) + + it('FE-DB-OFFLINE-023: deleteCurrentUserDb wipes the account data and returns to anonymous', async () => { + await reopenForUser(9) + await upsertTrip(buildTrip({ id: 501 })) + + await deleteCurrentUserDb() + expect(offlineDb.name).toBe('trek-offline') + + await reopenForUser(9) + expect(await offlineDb.trips.count()).toBe(0) + }) + + it('FE-DB-OFFLINE-024: deleteCurrentUserDb on the anonymous database is a no-op switch', async () => { + await reopenAnonymous() + await upsertTrip(buildTrip({ id: 502 })) + + await deleteCurrentUserDb() + + expect(offlineDb.name).toBe('trek-offline') + expect(await offlineDb.trips.get(502)).toBeDefined() + }) +}) + +describe('offlineDb β€” connection proxy', () => { + it('FE-DB-OFFLINE-028: reads and writes go to the connection that is live right now', async () => { + const proxied = offlineDb as unknown as Record + proxied.__marker = 'anon' + expect(proxied.__marker).toBe('anon') + + await reopenForUser(56) + expect((offlineDb as unknown as Record).__marker).toBeUndefined() + }) + + it('FE-DB-OFFLINE-029: upgrading a pre-v3 cache backfills tripId and bytes on blob rows', async () => { + const legacy = new Dexie('trek-offline-u55') + legacy.version(1).stores({ + trips: 'id', + days: 'id, trip_id', + places: 'id, trip_id', + packingItems: 'id, trip_id', + todoItems: 'id, trip_id', + budgetItems: 'id, trip_id', + reservations: 'id, trip_id', + tripFiles: 'id, trip_id', + mutationQueue: 'id, tripId, status, createdAt', + syncMeta: 'tripId', + blobCache: 'url, cachedAt', + }) + legacy.version(2).stores({ + accommodations: 'id, trip_id', + tripMembers: '[tripId+id], tripId', + tags: 'id', + categories: 'id', + }) + await legacy.open() + await legacy.table('blobCache').put({ url: '/legacy.pdf', blob: new Blob(['abc']), mime: 'application/pdf', cachedAt: 1 }) + legacy.close() + + await reopenForUser(55) + + const row = await offlineDb.blobCache.get('/legacy.pdf') + expect(row!.tripId).toBe(-1) + expect(typeof row!.bytes).toBe('number') + }) +}) + +describe('offlineDb β€” initial database name', () => { + it('FE-DB-OFFLINE-025: boots straight into the persisted user\'s database', async () => { + localStorage.setItem('trek_auth_snapshot', JSON.stringify({ state: { user: { id: 33 } } })) + vi.resetModules() + + const mod = await import('./offlineDb') + expect(mod.offlineDb.name).toBe('trek-offline-u33') + }) + + it('FE-DB-OFFLINE-026: falls back to the anonymous database without a snapshot', async () => { + localStorage.removeItem('trek_auth_snapshot') + vi.resetModules() + + const mod = await import('./offlineDb') + expect(mod.offlineDb.name).toBe('trek-offline') + }) + + it('FE-DB-OFFLINE-027: a corrupt snapshot falls back to the anonymous database', async () => { + localStorage.setItem('trek_auth_snapshot', 'not json') + vi.resetModules() + + const mod = await import('./offlineDb') + expect(mod.offlineDb.name).toBe('trek-offline') + }) +}) diff --git a/client/src/hooks/useCountUp.test.ts b/client/src/hooks/useCountUp.test.ts new file mode 100644 index 000000000..5b1efd9ff --- /dev/null +++ b/client/src/hooks/useCountUp.test.ts @@ -0,0 +1,118 @@ +// FE-HOOK-COUNTUP-001 to FE-HOOK-COUNTUP-006 +import { act, renderHook } from '@testing-library/react'; + +// The hook reads navigator.userAgent once at module load to detect jsdom and +// skip the animation. Every test therefore imports it fresh, with the user +// agent already faked to whatever that test needs. +async function loadHook(userAgent: string) { + vi.resetModules(); + Object.defineProperty(navigator, 'userAgent', { value: userAgent, configurable: true }); + return (await import('./useCountUp')).useCountUp; +} + +const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/131.0.0.0'; +const JSDOM_UA = 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 jsdom/26.0.0'; + +/** Drives requestAnimationFrame by hand so the easing is deterministic. */ +function stubRaf(): { flush: (now: number) => void; cancelled: number[] } { + const pending = new Map(); + const cancelled: number[] = []; + let id = 0; + vi.stubGlobal('requestAnimationFrame', vi.fn((cb: FrameRequestCallback) => { + pending.set(++id, cb); + return id; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn((handle: number) => { + cancelled.push(handle); + pending.delete(handle); + })); + return { + flush(now: number) { + const due = [...pending.entries()]; + pending.clear(); + for (const [, cb] of due) cb(now); + }, + cancelled, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); + Object.defineProperty(navigator, 'userAgent', { value: JSDOM_UA, configurable: true }); +}); + +describe('useCountUp', () => { + it('FE-HOOK-COUNTUP-001: returns the target immediately under jsdom', async () => { + const useCountUp = await loadHook(JSDOM_UA); + const { result } = renderHook(() => useCountUp(42)); + expect(result.current).toBe(42); + }); + + it('FE-HOOK-COUNTUP-002: animates from 0 to the target with an ease-out curve', async () => { + const raf = stubRaf(); + const useCountUp = await loadHook(BROWSER_UA); + + const { result } = renderHook(() => useCountUp(100, 1000)); + expect(result.current).toBe(0); + + // First frame establishes the start timestamp, so it still reads 0. + act(() => raf.flush(1000)); + expect(result.current).toBe(0); + + // Half way through, ease-out-quint is already at 1 - 0.5^5 = 96.875%. + act(() => raf.flush(1500)); + expect(result.current).toBe(97); + + act(() => raf.flush(2000)); + expect(result.current).toBe(100); + }); + + it('FE-HOOK-COUNTUP-003: stops requesting frames once it reaches the target', async () => { + const raf = stubRaf(); + const useCountUp = await loadHook(BROWSER_UA); + + const { result } = renderHook(() => useCountUp(50, 400)); + act(() => raf.flush(0)); + act(() => raf.flush(400)); + expect(result.current).toBe(50); + + // No further frame was scheduled, so flushing again changes nothing. + act(() => raf.flush(800)); + expect(result.current).toBe(50); + }); + + it('FE-HOOK-COUNTUP-004: cancels the pending frame on unmount', async () => { + const raf = stubRaf(); + const useCountUp = await loadHook(BROWSER_UA); + + const { unmount } = renderHook(() => useCountUp(80, 600)); + act(() => raf.flush(0)); + unmount(); + expect(raf.cancelled.length).toBeGreaterThan(0); + }); + + it('FE-HOOK-COUNTUP-005: skips the animation when reduced motion is preferred', async () => { + stubRaf(); + const useCountUp = await loadHook(BROWSER_UA); + const matchMedia = vi.mocked(window.matchMedia); + matchMedia.mockReturnValueOnce({ matches: true } as unknown as MediaQueryList); + + const { result } = renderHook(() => useCountUp(64)); + expect(result.current).toBe(64); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-COUNTUP-006: passes non-positive targets straight through', async () => { + stubRaf(); + const useCountUp = await loadHook(BROWSER_UA); + + const { result, rerender } = renderHook(({ target }) => useCountUp(target), { + initialProps: { target: 0 }, + }); + expect(result.current).toBe(0); + + rerender({ target: -5 }); + expect(result.current).toBe(-5); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/hooks/useElementRect.test.ts b/client/src/hooks/useElementRect.test.ts new file mode 100644 index 000000000..331e95719 --- /dev/null +++ b/client/src/hooks/useElementRect.test.ts @@ -0,0 +1,101 @@ +// FE-HOOK-ELRECT-001 to FE-HOOK-ELRECT-007 +import { act, renderHook } from '@testing-library/react'; +import { useElementRect } from './useElementRect'; + +const observers: { fire: () => void; disconnected: boolean }[] = []; + +class ControllableResizeObserver { + private entry: { fire: () => void; disconnected: boolean } | null = null; + constructor(private cb: ResizeObserverCallback) {} + observe() { + this.entry = { fire: () => this.cb([], this as unknown as ResizeObserver), disconnected: false }; + observers.push(this.entry); + } + unobserve() {} + disconnect() { + if (this.entry) this.entry.disconnected = true; + } +} + +function element(left: number, width: number): HTMLElement { + const el = document.createElement('div'); + el.getBoundingClientRect = () => ({ left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON: () => ({}) }); + return el; +} + +function moveTo(el: HTMLElement, left: number, width: number): void { + el.getBoundingClientRect = () => ({ left, width, right: left + width, top: 0, bottom: 0, height: 0, x: left, y: 0, toJSON: () => ({}) }); +} + +beforeEach(() => { + observers.length = 0; + vi.stubGlobal('ResizeObserver', ControllableResizeObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useElementRect', () => { + it('FE-HOOK-ELRECT-001: has no rect until an element is attached', () => { + const { result } = renderHook(() => useElementRect()); + expect(result.current.rect).toBeNull(); + }); + + it('FE-HOOK-ELRECT-002: measures left and width on attach', () => { + const { result } = renderHook(() => useElementRect()); + act(() => result.current.ref(element(240, 400))); + expect(result.current.rect).toEqual({ left: 240, width: 400 }); + }); + + it('FE-HOOK-ELRECT-003: re-measures when the element resizes', () => { + const { result } = renderHook(() => useElementRect()); + const el = element(240, 400); + act(() => result.current.ref(el)); + + moveTo(el, 120, 560); + act(() => observers[0].fire()); + + expect(result.current.rect).toEqual({ left: 120, width: 560 }); + }); + + it('FE-HOOK-ELRECT-004: re-measures on a window resize', () => { + const { result } = renderHook(() => useElementRect()); + const el = element(240, 400); + act(() => result.current.ref(el)); + + moveTo(el, 0, 800); + act(() => window.dispatchEvent(new Event('resize'))); + + expect(result.current.rect).toEqual({ left: 0, width: 800 }); + }); + + it('FE-HOOK-ELRECT-005: keeps the previous rect object when nothing moved', () => { + const { result } = renderHook(() => useElementRect()); + act(() => result.current.ref(element(240, 400))); + const before = result.current.rect; + + act(() => observers[0].fire()); + + expect(result.current.rect).toBe(before); + }); + + it('FE-HOOK-ELRECT-006: clears the rect and disconnects when the ref detaches', () => { + const { result } = renderHook(() => useElementRect()); + act(() => result.current.ref(element(240, 400))); + act(() => result.current.ref(null)); + + expect(result.current.rect).toBeNull(); + expect(observers[0].disconnected).toBe(true); + }); + + it('FE-HOOK-ELRECT-007: drops the window listener on unmount', () => { + const remove = vi.spyOn(window, 'removeEventListener'); + const { result, unmount } = renderHook(() => useElementRect()); + act(() => result.current.ref(element(10, 20))); + unmount(); + + expect(remove).toHaveBeenCalledWith('resize', expect.any(Function)); + remove.mockRestore(); + }); +}); diff --git a/client/src/hooks/useElementSize.test.ts b/client/src/hooks/useElementSize.test.ts new file mode 100644 index 000000000..e3fbce1d6 --- /dev/null +++ b/client/src/hooks/useElementSize.test.ts @@ -0,0 +1,105 @@ +// FE-HOOK-ELSIZE-001 to FE-HOOK-ELSIZE-006 +import { act, renderHook } from '@testing-library/react'; +import { useElementSize } from './useElementSize'; + +// tests/setup.ts installs a no-op ResizeObserver. Swap in one that hands the +// callback back so a resize can be simulated, and count the connections so the +// disconnect-on-detach contract is observable. +const observers: { el: Element; fire: () => void; disconnected: boolean }[] = []; + +class ControllableResizeObserver { + private entry: { el: Element; fire: () => void; disconnected: boolean } | null = null; + constructor(private cb: ResizeObserverCallback) {} + observe(el: Element) { + this.entry = { el, fire: () => this.cb([], this as unknown as ResizeObserver), disconnected: false }; + observers.push(this.entry); + } + unobserve() {} + disconnect() { + if (this.entry) this.entry.disconnected = true; + } +} + +function element(width: number, height: number): HTMLElement { + const el = document.createElement('div'); + Object.defineProperty(el, 'offsetWidth', { value: width, configurable: true }); + Object.defineProperty(el, 'offsetHeight', { value: height, configurable: true }); + return el; +} + +function resize(el: HTMLElement, width: number, height: number): void { + Object.defineProperty(el, 'offsetWidth', { value: width, configurable: true }); + Object.defineProperty(el, 'offsetHeight', { value: height, configurable: true }); +} + +beforeEach(() => { + observers.length = 0; + vi.stubGlobal('ResizeObserver', ControllableResizeObserver); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useElementSize', () => { + it('FE-HOOK-ELSIZE-001: starts at zero before an element is attached', () => { + const { result } = renderHook(() => useElementSize()); + expect(result.current.width).toBe(0); + expect(result.current.height).toBe(0); + expect(observers).toHaveLength(0); + }); + + it('FE-HOOK-ELSIZE-002: measures the element as soon as the ref is attached', () => { + const { result } = renderHook(() => useElementSize()); + act(() => result.current.ref(element(320, 180))); + + expect(result.current.width).toBe(320); + expect(result.current.height).toBe(180); + expect(observers).toHaveLength(1); + }); + + it('FE-HOOK-ELSIZE-003: picks up a later resize of the same element', () => { + const { result } = renderHook(() => useElementSize()); + const el = element(320, 180); + act(() => result.current.ref(el)); + + resize(el, 640, 400); + act(() => observers[0].fire()); + + expect(result.current.width).toBe(640); + expect(result.current.height).toBe(400); + }); + + it('FE-HOOK-ELSIZE-004: keeps the same state object when the size is unchanged', () => { + const { result } = renderHook(() => useElementSize()); + act(() => result.current.ref(element(200, 100))); + const before = result.current; + + act(() => observers[0].fire()); + + // No re-render β€” the hook bailed out of setState because nothing moved. + expect(result.current.width).toBe(before.width); + expect(result.current.height).toBe(before.height); + }); + + it('FE-HOOK-ELSIZE-005: disconnects the old observer when the ref moves to another element', () => { + const { result } = renderHook(() => useElementSize()); + act(() => result.current.ref(element(100, 50))); + act(() => result.current.ref(element(300, 150))); + + expect(observers).toHaveLength(2); + expect(observers[0].disconnected).toBe(true); + expect(observers[1].disconnected).toBe(false); + expect(result.current.width).toBe(300); + }); + + it('FE-HOOK-ELSIZE-006: disconnects when React detaches the ref with null', () => { + const { result } = renderHook(() => useElementSize()); + act(() => result.current.ref(element(100, 50))); + act(() => result.current.ref(null)); + + expect(observers[0].disconnected).toBe(true); + // The last measured size is kept β€” only the observation stops. + expect(result.current.width).toBe(100); + }); +}); diff --git a/client/src/hooks/useGeolocation.test.ts b/client/src/hooks/useGeolocation.test.ts new file mode 100644 index 000000000..fb9ce889c --- /dev/null +++ b/client/src/hooks/useGeolocation.test.ts @@ -0,0 +1,261 @@ +// FE-HOOK-GEO-001 to FE-HOOK-GEO-014 +import { act, renderHook } from '@testing-library/react'; +import { useGeolocation } from './useGeolocation'; + +type WatchSuccess = (pos: GeolocationPosition) => void; +type WatchFailure = (err: GeolocationPositionError) => void; + +let successCb: WatchSuccess | null = null; +let errorCb: WatchFailure | null = null; +let watchPosition: ReturnType; +let clearWatch: ReturnType; + +function fix(over: Partial = {}, timestamp = 1_700_000_000_000): GeolocationPosition { + return { + coords: { latitude: 48.2, longitude: 16.37, accuracy: 12, heading: null, speed: null, altitude: null, altitudeAccuracy: null, ...over }, + timestamp, + } as GeolocationPosition; +} + +function geoError(code: number, message = 'Location unavailable'): GeolocationPositionError { + return { code, message, PERMISSION_DENIED: 1, POSITION_UNAVAILABLE: 2, TIMEOUT: 3 } as GeolocationPositionError; +} + +/** Dispatches an orientation event the hook's listener will pick up. */ +function orient(props: Record): void { + const ev = new Event('deviceorientationabsolute'); + Object.assign(ev, props); + window.dispatchEvent(ev); +} + +beforeEach(() => { + successCb = null; + errorCb = null; + watchPosition = vi.fn((ok: WatchSuccess, fail: WatchFailure) => { + successCb = ok; + errorCb = fail; + return 7; + }); + clearWatch = vi.fn(); + Object.defineProperty(navigator, 'geolocation', { + value: { watchPosition, clearWatch }, + configurable: true, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('useGeolocation', () => { + it('FE-HOOK-GEO-001: starts off with nothing tracked', () => { + const { result } = renderHook(() => useGeolocation()); + expect(result.current.mode).toBe('off'); + expect(result.current.position).toBeNull(); + expect(result.current.error).toBeNull(); + expect(watchPosition).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-GEO-002: cycles off β†’ show β†’ follow β†’ off', async () => { + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + expect(result.current.mode).toBe('show'); + expect(watchPosition).toHaveBeenCalledTimes(1); + + await act(() => result.current.cycleMode()); + expect(result.current.mode).toBe('follow'); + + await act(() => result.current.cycleMode()); + expect(result.current.mode).toBe('off'); + expect(clearWatch).toHaveBeenCalledWith(7); + }); + + it('FE-HOOK-GEO-003: requests a high-accuracy watch', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + expect(watchPosition.mock.calls[0][2]).toEqual({ + enableHighAccuracy: true, + maximumAge: 2000, + timeout: 15000, + }); + }); + + it('FE-HOOK-GEO-004: maps a fix onto the position shape', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => successCb!(fix({ heading: 90, speed: 3.5 }))); + + expect(result.current.position).toEqual({ + lat: 48.2, lng: 16.37, accuracy: 12, heading: 90, speed: 3.5, timestamp: 1_700_000_000_000, + }); + }); + + it('FE-HOOK-GEO-005: reports an error but stays subscribed when the fix merely fails', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(2, 'Position unavailable'))); + + expect(result.current.error).toBe('Position unavailable'); + expect(result.current.mode).toBe('show'); + expect(clearWatch).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-GEO-006: falls back to a generic message when the error carries none', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(3, ''))); + expect(result.current.error).toBe('Location unavailable'); + }); + + it('FE-HOOK-GEO-007: a permission denial stops tracking entirely', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(1, 'User denied Geolocation'))); + + expect(result.current.mode).toBe('off'); + expect(clearWatch).toHaveBeenCalledWith(7); + }); + + it('FE-HOOK-GEO-008: refuses to start when the browser has no geolocation', async () => { + // The hook guards with `'geolocation' in navigator`, so the key has to go + // away entirely β€” a defined-but-undefined property would slip past it. + delete (navigator as { geolocation?: Geolocation }).geolocation; + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(result.current.mode).toBe('off'); + expect(result.current.error).toBe('Geolocation is not supported in this browser'); + }); + + it('FE-HOOK-GEO-009: prefers the webkit compass heading', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + + act(() => orient({ webkitCompassHeading: 120, alpha: 10, absolute: true })); + expect(result.current.position?.heading).toBe(120); + }); + + it('FE-HOOK-GEO-010: converts a counter-clockwise alpha into a clockwise heading', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + + act(() => orient({ alpha: 90, absolute: true })); + expect(result.current.position?.heading).toBe(270); + }); + + it('FE-HOOK-GEO-011: smooths successive headings instead of snapping', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + + act(() => orient({ alpha: 0, absolute: true })); + expect(result.current.position?.heading).toBe(0); + + // 0 β†’ 40 with alpha 0.25 lands a quarter of the way, not at 40. + act(() => orient({ alpha: 320, absolute: true })); + expect(result.current.position?.heading).toBeCloseTo(10, 5); + }); + + it('FE-HOOK-GEO-012: takes the short way around the 360Β° seam', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + + act(() => orient({ alpha: 10, absolute: true })); // heading 350 + act(() => orient({ alpha: 350, absolute: true })); // heading 10 β€” +20 across the seam + expect(result.current.position?.heading).toBeCloseTo(355, 5); + }); + + it('FE-HOOK-GEO-013a: still uses a non-absolute alpha as a fallback heading', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + + act(() => orient({ alpha: 90 })); + expect(result.current.position?.heading).toBe(270); + }); + + it('FE-HOOK-GEO-013: ignores orientation events without a usable angle', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix({ heading: 45 }))); + + act(() => orient({ alpha: null, absolute: true })); + expect(result.current.position?.heading).toBe(45); + }); + + it('FE-HOOK-GEO-014: keeps the compass heading when the GPS fix has none', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + act(() => successCb!(fix())); + act(() => orient({ alpha: 180, absolute: true })); + + act(() => successCb!(fix({ heading: null }, 1_700_000_001_000))); + expect(result.current.position?.heading).toBe(180); + }); + + it('FE-HOOK-GEO-015: setMode accepts a value and a derived updater', async () => { + const { result } = renderHook(() => useGeolocation()); + + act(() => result.current.setMode('show')); + expect(result.current.mode).toBe('show'); + expect(watchPosition).toHaveBeenCalledTimes(1); + + act(() => result.current.setMode(prev => (prev === 'show' ? 'follow' : prev))); + expect(result.current.mode).toBe('follow'); + // Already watching β€” no second subscription. + expect(watchPosition).toHaveBeenCalledTimes(1); + + act(() => result.current.setMode('off')); + expect(result.current.mode).toBe('off'); + expect(result.current.position).toBeNull(); + expect(clearWatch).toHaveBeenCalledWith(7); + }); + + it('FE-HOOK-GEO-016: asks iOS for orientation permission and proceeds either way', async () => { + const requestPermission = vi.fn().mockResolvedValue('denied'); + vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission })); + + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + expect(requestPermission).toHaveBeenCalled(); + expect(result.current.mode).toBe('show'); + }); + + it('FE-HOOK-GEO-017: survives an older webkit that throws from requestPermission', async () => { + const requestPermission = vi.fn().mockRejectedValue(new Error('not allowed')); + vi.stubGlobal('DeviceOrientationEvent', Object.assign(function () {}, { requestPermission })); + + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + expect(result.current.mode).toBe('show'); + expect(watchPosition).toHaveBeenCalled(); + }); + + it('FE-HOOK-GEO-018: tolerates clearWatch throwing while tearing down', async () => { + clearWatch.mockImplementation(() => { throw new Error('gone'); }); + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + expect(() => act(() => result.current.setMode('off'))).not.toThrow(); + }); + + it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => { + const { result, unmount } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + unmount(); + expect(clearWatch).toHaveBeenCalledWith(7); + }); +}); diff --git a/client/src/pages/AdminPage.test.tsx b/client/src/pages/AdminPage.test.tsx index f9be188c5..463383a74 100644 --- a/client/src/pages/AdminPage.test.tsx +++ b/client/src/pages/AdminPage.test.tsx @@ -4,6 +4,7 @@ import { buildAdmin } from '../../tests/helpers/factories'; import { server } from '../../tests/helpers/msw/server'; import { fireEvent, render, screen, waitFor, within } from '../../tests/helpers/render'; import { resetAllStores, seedStore } from '../../tests/helpers/store'; +import { ToastContainer } from '../components/shared/Toast'; import { useAuthStore } from '../store/authStore'; import AdminPage from './AdminPage'; @@ -20,8 +21,24 @@ vi.mock('../components/Admin/GitHubPanel', () => ({ default: () =>
, })); +// AddonManager is stubbed but keeps its two callbacks reachable β€” the page owns +// the optimistic toggle logic behind them. vi.mock('../components/Admin/AddonManager', () => ({ - default: () =>
, + default: ({ + bagTrackingEnabled, + onToggleBagTracking, + onToggleCollabFeature, + }: { + bagTrackingEnabled: boolean; + onToggleBagTracking: () => void; + onToggleCollabFeature: (key: string) => void; + }) => ( +
+ {String(bagTrackingEnabled)} + + +
+ ), })); vi.mock('../components/Admin/PackingTemplateManager', () => ({ @@ -44,8 +61,27 @@ vi.mock('../components/Admin/DevNotificationsPanel', () => ({ default: () =>
, })); +vi.mock('../components/Admin/AdminPluginsPanel', () => ({ + default: () =>
, +})); + +vi.mock('../components/Admin/DefaultUserSettingsTab', () => ({ + default: () =>
, +})); + +vi.mock('../mobile/screens/admin/MAdmin', () => ({ + default: () =>
, +})); + +// Viewport switch β€” flipped per test instead of at module scope. +let phoneViewport = false; +vi.mock('../mobile/useIsPhone', () => ({ + useIsPhone: () => phoneViewport, +})); + beforeEach(() => { resetAllStores(); + phoneViewport = false; }); describe('AdminPage', () => { @@ -1406,4 +1442,216 @@ describe('AdminPage', () => { expect((clientIdInput as HTMLInputElement).value).toBe('my-client-id'); }); }); + + describe('FE-PAGE-ADMIN-054: Demo baseline button', () => { + it('is hidden unless the instance runs in demo mode', async () => { + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + + expect(screen.queryByRole('button', { name: /save baseline/i })).not.toBeInTheDocument(); + }); + + it('saves the baseline and reports success in demo mode', async () => { + let saved = false; + server.use( + http.post('/api/admin/save-demo-baseline', () => { + saved = true; + return HttpResponse.json({ success: true }); + }) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true }); + render( + <> + + + + ); + + fireEvent.click(await screen.findByRole('button', { name: /save baseline/i })); + + await waitFor(() => expect(saved).toBe(true)); + expect(await screen.findByText(/baseline saved/i)).toBeInTheDocument(); + }); + + it('surfaces the server error when the baseline cannot be saved', async () => { + server.use( + http.post('/api/admin/save-demo-baseline', () => + HttpResponse.json({ error: 'Baseline locked' }, { status: 500 }) + ) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true }); + render( + <> + + + + ); + + fireEvent.click(await screen.findByRole('button', { name: /save baseline/i })); + + expect(await screen.findByText('Baseline locked')).toBeInTheDocument(); + }); + }); + + describe('FE-PAGE-ADMIN-055: Addons tab toggles', () => { + it('bag tracking flips optimistically and PUTs the new value', async () => { + let body: Record | null = null; + server.use( + http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false })), + http.put('/api/admin/bag-tracking', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ enabled: true }); + }) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^addons$/i })); + + fireEvent.click(screen.getByRole('button', { name: /toggle bag tracking/i })); + + expect(screen.getByTestId('bag-tracking-state')).toHaveTextContent('true'); + await waitFor(() => expect(body).toEqual({ enabled: true })); + }); + + it('bag tracking rolls back when the request fails', async () => { + server.use( + http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: false })), + http.put('/api/admin/bag-tracking', () => HttpResponse.json({}, { status: 500 })) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^addons$/i })); + + fireEvent.click(screen.getByRole('button', { name: /toggle bag tracking/i })); + + await waitFor(() => expect(screen.getByTestId('bag-tracking-state')).toHaveTextContent('false')); + }); + + it('a collab feature toggle PUTs only the changed key', async () => { + let body: Record | null = null; + server.use( + http.get('/api/admin/collab-features', () => + HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true }) + ), + http.put('/api/admin/collab-features', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^addons$/i })); + + fireEvent.click(screen.getByRole('button', { name: /toggle chat/i })); + + await waitFor(() => expect(body).toEqual({ chat: false })); + }); + + it('a failing collab toggle restores the previous feature set', async () => { + server.use( + http.get('/api/admin/collab-features', () => + HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true }) + ), + http.put('/api/admin/collab-features', () => HttpResponse.json({}, { status: 500 })) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /^addons$/i })); + + fireEvent.click(screen.getByRole('button', { name: /toggle chat/i })); + + // The rollback re-renders the stub; the addon panel stays mounted either way + await waitFor(() => expect(screen.getByTestId('addon-manager')).toBeInTheDocument()); + }); + }); + + describe('FE-PAGE-ADMIN-056: Dev notifications tab', () => { + it('is hidden unless devMode is on and renders the panel when it is', async () => { + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + const { unmount } = render(); + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: /dev: notifications/i })).not.toBeInTheDocument(); + unmount(); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), devMode: true }); + render(); + const devTab = await screen.findByRole('button', { name: /dev: notifications/i }); + fireEvent.click(devTab); + + expect(screen.getByTestId('dev-notifications-panel')).toBeInTheDocument(); + }); + }); + + describe('FE-PAGE-ADMIN-057: Remaining tab panels', () => { + it('renders the personalization, plugins and user-defaults panels', async () => { + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /^personalization$/i })); + expect(screen.getByTestId('packing-template-manager')).toBeInTheDocument(); + expect(screen.getByTestId('category-manager')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^plugins$/i })); + expect(screen.getByTestId('plugins-panel')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /user defaults/i })); + expect(screen.getByTestId('default-user-settings')).toBeInTheDocument(); + }); + }); + + describe('FE-PAGE-ADMIN-058: Phone viewport', () => { + it('renders the mobile admin screen instead of the desktop layout', async () => { + phoneViewport = true; + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + + render(); + + expect(screen.getByTestId('mobile-admin')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^users$/i })).not.toBeInTheDocument(); + }); + }); + + describe('FE-PAGE-ADMIN-059: Stats fallbacks', () => { + it('shows a zero file count when the API omits totalFiles', async () => { + server.use( + http.get('/api/admin/stats', () => + HttpResponse.json({ totalUsers: 3, totalTrips: 11, totalPlaces: 42 }) + ) + ); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + const filesLabel = await screen.findByText('Files'); + expect(within(filesLabel.closest('div.rounded-xl')!).getByText('0')).toBeInTheDocument(); + }); + + it('hides the stats grid entirely when stats cannot be loaded', async () => { + server.use(http.get('/api/admin/stats', () => HttpResponse.json({}, { status: 500 }))); + + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /^users$/i })).toBeInTheDocument()); + expect(screen.queryByText('Files')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/src/pages/AtlasPage.wiring.test.tsx b/client/src/pages/AtlasPage.wiring.test.tsx new file mode 100644 index 000000000..c3bb4a412 --- /dev/null +++ b/client/src/pages/AtlasPage.wiring.test.tsx @@ -0,0 +1,665 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor } from '../../tests/helpers/render'; +import { server } from '../../tests/helpers/msw/server'; +import { buildAtlasController, buildAtlasData, buildBucketItem, buildCountryDetail, type VisitedRegions } from '../../tests/helpers/atlas'; +import type { AtlasController } from '../mobile/screens/atlas/atlasController'; +import type { AtlasData, CountryDetail } from './atlas/atlasModel'; +import AtlasPage from './AtlasPage'; + +// FE-PAGE-ATLASW-001 to FE-PAGE-ATLASW-026 +// +// AtlasPage is a wiring container around useAtlas: the hook is mocked here so the +// confirm popup and the sidebar can be driven into every state directly, which the +// map-click driven suite in AtlasPage.test.tsx cannot reach. + +const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController, isPhone: false })); + +vi.mock('./atlas/useAtlas', () => ({ useAtlas: () => mocks.atlas })); +vi.mock('../mobile/useIsPhone', () => ({ useIsPhone: () => mocks.isPhone })); +vi.mock('../components/Layout/Navbar', () => ({ + default: () => React.createElement('nav', { 'data-testid': 'navbar' }), +})); + +const toast = vi.fn(() => 0); + +function setAtlas(over: Record = {}): AtlasController { + mocks.atlas = buildAtlasController(over); + return mocks.atlas; +} + +/** The two CustomSelect triggers in a popup both render the "β€”" placeholder option. */ +function selectTriggers(): HTMLElement[] { + return screen.getAllByRole('button').filter((b) => b.textContent?.trim() === 'β€”'); +} + +beforeEach(() => { + mocks.isPhone = false; + toast.mockClear(); + window.__addToast = toast; + setAtlas(); + server.use( + http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })), + http.post('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ success: true })), + http.delete('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ success: true })), + http.post('/api/addons/atlas/bucket-list', () => + HttpResponse.json({ item: buildBucketItem({ id: 99, name: 'Germany', country_code: 'DE' }) }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('AtlasPage wiring', () => { + it('FE-PAGE-ATLASW-001: hands the whole screen to the mobile atlas on a phone', () => { + mocks.isPhone = true; + render(); + + expect(screen.getByRole('button', { name: 'atlas.bucketTab' })).toBeInTheDocument(); + expect(screen.queryByTestId('navbar')).not.toBeInTheDocument(); + }); + + it('FE-PAGE-ATLASW-002: shows navbar and spinner while the atlas is loading', () => { + setAtlas({ loading: true }); + render(); + + expect(screen.getByTestId('navbar')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + describe('country popup', () => { + it('FE-PAGE-ATLASW-003: the choose popup marks a country and updates the local atlas data', async () => { + const atlas = setAtlas({ confirmAction: { type: 'choose', code: 'DE', name: 'Germany' } }); + render(); + + const flag = document.querySelector('img[alt="DE"]') as HTMLImageElement; + expect(flag.src).toContain('flagcdn.com/w80/de.png'); + + const markBtn = screen.getByText('atlas.markVisited').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(markBtn); + expect(markBtn.style.background).toBe('var(--bg-secondary)'); + fireEvent.mouseLeave(markBtn); + expect(markBtn.style.background).toBe('none'); + + fireEvent.click(markBtn); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + const data = atlas.data as AtlasData; + expect(data.countries.map((c) => c.code)).toEqual(['FR', 'DE']); + expect(data.stats.totalCountries).toBe(2); + expect(data.continents?.Europe).toBe(2); + }); + + it('FE-PAGE-ATLASW-004: a failing mark shows the server error as a toast', async () => { + setAtlas({ confirmAction: { type: 'choose', code: 'DE', name: 'Germany' } }); + server.use( + http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ error: 'Locked' }, { status: 400 })), + ); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Locked', 'error', undefined)); + }); + + it('FE-PAGE-ATLASW-005: the bucket option and the backdrop drive the pending action', () => { + const action = { type: 'choose', code: 'DE', name: 'Germany' } as AtlasController['confirmAction']; + const atlas = setAtlas({ confirmAction: action }); + render(); + + const bucketBtn = screen.getByText('atlas.addToBucket').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(bucketBtn); + fireEvent.mouseLeave(bucketBtn); + fireEvent.click(bucketBtn); + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' }); + + fireEvent.click(screen.getByText('Germany').closest('div[style*="z-index: 1000"]') as HTMLElement); + expect(atlas.setConfirmAction).toHaveBeenLastCalledWith(null); + }); + + it('FE-PAGE-ATLASW-006: a three-letter code falls back to the flag glyph', () => { + setAtlas({ confirmAction: { type: 'choose', code: 'DEU', name: 'Germany' } }); + render(); + + expect(document.querySelector('img[alt="DEU"]')).toBeNull(); + expect(screen.getByText('Germany')).toBeInTheDocument(); + }); + + it('FE-PAGE-ATLASW-007: the region popup marks a region and adopts its country', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA', countryName: 'Spain' }, + }); + render(); + + expect(screen.getByText('Spain')).toBeInTheDocument(); + const markBtn = screen.getByText('atlas.markVisited').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(markBtn); + fireEvent.mouseLeave(markBtn); + fireEvent.click(markBtn); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect((atlas.visitedRegions as VisitedRegions).ES).toEqual([ + { code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }, + ]); + expect((atlas.data as AtlasData).countries.map((c) => c.code)).toContain('ES'); + }); + + it('FE-PAGE-ATLASW-008: an already recorded region is not added twice and the country stays put', async () => { + const regions: VisitedRegions = { FR: [{ code: 'FR-BRE', name: 'Bretagne', placeCount: 2 }] }; + const atlas = setAtlas({ + confirmAction: { type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' }, + visitedRegions: regions, + }); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(atlas.visitedRegions).toBe(regions); + expect((atlas.data as AtlasData).countries).toHaveLength(1); + }); + + it('FE-PAGE-ATLASW-009: a failing region mark shows the error toast', async () => { + setAtlas({ confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' } }); + server.use( + http.post('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Nope' }, { status: 422 })), + ); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Nope', 'error', undefined)); + }); + + it('FE-PAGE-ATLASW-010: a region popup without a region code cannot be confirmed', async () => { + const atlas = setAtlas({ confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia' } }); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + fireEvent.click(screen.getByText('atlas.addToBucket')); + + expect(atlas.setVisitedRegions).not.toHaveBeenCalled(); + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ type: 'bucket', code: 'ES', name: 'Galicia' }); + }); + + it('FE-PAGE-ATLASW-010b: the region bucket option highlights on hover', () => { + const action = { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' } as AtlasController['confirmAction']; + const atlas = setAtlas({ confirmAction: action }); + render(); + + const bucketBtn = screen.getByText('atlas.addToBucket').closest('button') as HTMLButtonElement; + fireEvent.mouseEnter(bucketBtn); + expect(bucketBtn.style.background).toBe('var(--bg-secondary)'); + fireEvent.mouseLeave(bucketBtn); + expect(bucketBtn.style.background).toBe('none'); + + fireEvent.click(bucketBtn); + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' }); + }); + + it('FE-PAGE-ATLASW-010c: a region removal without a region code performs no request', async () => { + const deleteSpy = vi.fn(); + server.use( + http.delete('/api/addons/atlas/region/:code/mark', () => { deleteSpy(); return HttpResponse.json({}); }), + ); + const atlas = setAtlas({ confirmAction: { type: 'unmark-region', code: 'ES', name: 'Galicia' } }); + render(); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setVisitedRegions).not.toHaveBeenCalled()); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('FE-PAGE-ATLASW-010d: marking a country that is already known leaves the totals alone', async () => { + const atlas = setAtlas({ confirmAction: { type: 'choose', code: 'FR', name: 'France' } }); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect((atlas.data as AtlasData).stats.totalCountries).toBe(1); + }); + + it('FE-PAGE-ATLASW-010e: region actions cope with atlas data that never loaded', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'unmark-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' }, + data: null, + }); + render(); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(atlas.data).toBeNull(); + }); + + it('FE-PAGE-ATLASW-011: the unmark popup cancels or delegates to the shared executor', () => { + const atlas = setAtlas({ confirmAction: { type: 'unmark', code: 'FR', name: 'France' } }); + render(); + + expect(screen.getByText('atlas.confirmUnmark')).toBeInTheDocument(); + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + + fireEvent.click(screen.getByText('atlas.unmark')); + expect(atlas.executeConfirmAction).toHaveBeenCalled(); + }); + + it('FE-PAGE-ATLASW-012: the mark confirmation cancels or delegates to the shared executor', () => { + const atlas = setAtlas({ confirmAction: { type: 'mark', code: 'DE', name: 'Germany' } }); + render(); + + expect(screen.getByText('atlas.confirmMark')).toBeInTheDocument(); + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + + fireEvent.click(screen.getByText('atlas.markVisited')); + expect(atlas.executeConfirmAction).toHaveBeenCalled(); + }); + + it('FE-PAGE-ATLASW-013: removing the last region of a data-free country drops the country as well', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62', countryName: 'Italy' }, + visitedRegions: { IT: [{ code: 'IT-62', name: 'Lazio', placeCount: 0 }] } as VisitedRegions, + data: buildAtlasData({ + countries: [ + { code: 'FR', tripCount: 2, placeCount: 5 }, + { code: 'IT', tripCount: 0, placeCount: 0 }, + ], + stats: { totalTrips: 3, totalPlaces: 10, totalCountries: 2, totalDays: 14 }, + continents: { Europe: 2 }, + }), + }); + render(); + + expect(screen.getByText('Italy')).toBeInTheDocument(); + expect(screen.getByText('atlas.confirmUnmarkRegion')).toBeInTheDocument(); + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenLastCalledWith(null)); + expect(atlas.visitedRegions).toEqual({}); + const data = atlas.data as AtlasData; + expect(data.countries.map((c) => c.code)).toEqual(['FR']); + expect(data.stats.totalCountries).toBe(1); + expect(data.continents?.Europe).toBe(1); + }); + + it('FE-PAGE-ATLASW-014: a country with other regions left keeps its entry', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' }, + visitedRegions: { + IT: [ + { code: 'IT-62', name: 'Lazio', placeCount: 0 }, + { code: 'IT-25', name: 'Lombardia', placeCount: 0 }, + ], + } as VisitedRegions, + data: buildAtlasData({ + countries: [{ code: 'IT', tripCount: 0, placeCount: 0 }], + stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 }, + }), + }); + render(); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect((atlas.visitedRegions as VisitedRegions).IT.map((r) => r.code)).toEqual(['IT-25']); + expect((atlas.data as AtlasData).countries).toHaveLength(1); + }); + + it('FE-PAGE-ATLASW-015: a failing region removal shows the error toast', async () => { + setAtlas({ confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' } }); + server.use( + http.delete('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Boom' }, { status: 500 })), + ); + render(); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Boom', 'error', undefined)); + }); + + it('FE-PAGE-ATLASW-016: the bucket step offers a month and a year picker', () => { + const atlas = setAtlas({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + render(); + + expect(screen.getByText('atlas.bucketWhen')).toBeInTheDocument(); + + const [monthTrigger, yearTrigger] = selectTriggers(); + fireEvent.click(monthTrigger); + fireEvent.click(screen.getByText('March')); + expect(atlas.setBucketMonth).toHaveBeenCalledWith(3); + + const nextYear = String(new Date().getFullYear() + 1); + fireEvent.click(yearTrigger); + fireEvent.click(screen.getByText(nextYear)); + expect(atlas.setBucketYear).toHaveBeenCalledWith(Number(nextYear)); + }); + + it('FE-PAGE-ATLASW-016b: a chosen month and year is posted as the target date and reset afterwards', async () => { + let body: unknown = null; + server.use( + http.post('/api/addons/atlas/bucket-list', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ item: buildBucketItem({ id: 99, name: 'Germany', country_code: 'DE', target_date: '2027-05' }) }); + }), + ); + const atlas = setAtlas({ + confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' }, + bucketMonth: 5, + bucketYear: 2027, + }); + render(); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenLastCalledWith(null)); + expect(body).toEqual({ name: 'Germany', country_code: 'DE', target_date: '2027-05' }); + expect(atlas.bucketList as unknown[]).toHaveLength(1); + expect(atlas.setBucketMonth).toHaveBeenLastCalledWith(0); + expect(atlas.setBucketYear).toHaveBeenLastCalledWith(0); + }); + + it('FE-PAGE-ATLASW-017: without a month and year the bucket item is posted open-ended', async () => { + let body: unknown = null; + server.use( + http.post('/api/addons/atlas/bucket-list', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ item: buildBucketItem({ id: 100, name: 'Bretagne' }) }); + }), + ); + const atlas = setAtlas({ confirmAction: { type: 'bucket', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE' } }); + render(); + + fireEvent.click(screen.getByText('common.back')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ + type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', + }); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + await waitFor(() => expect(body).toEqual({ name: 'Bretagne', country_code: 'FR', target_date: null })); + }); + + it('FE-PAGE-ATLASW-018: a failing bucket post shows the error toast', async () => { + server.use( + http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Full' }, { status: 400 })), + ); + setAtlas({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + render(); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Full', 'error', undefined)); + }); + }); + + describe('sidebar', () => { + it('FE-PAGE-ATLASW-019: the stats tab lists totals, continents ordered by size and the last trip', () => { + const atlas = setAtlas({ + data: buildAtlasData({ + continents: { Europe: 3, Asia: 5, Africa: 1 }, + stats: { totalTrips: 6, totalPlaces: 40, totalCountries: 9, totalDays: 55, totalCities: 12 }, + streak: 1, + tripsThisYear: 1, + }), + }); + render(); + + // Every total shows twice: once in the mobile bar, once in the desktop panel. + expect(screen.getAllByText('9')).toHaveLength(2); + expect(screen.getAllByText('40')).toHaveLength(2); + expect(screen.getByText('atlas.europe')).toBeInTheDocument(); + // Singular labels are picked when streak / tripsThisYear are 1. + expect(screen.getByText('atlas.yearInRow')).toBeInTheDocument(); + expect(screen.getByText(/atlas\.tripIn/)).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Paris Trip')); + expect(atlas.navigate).toHaveBeenCalledWith('/trips/7'); + }); + + it('FE-PAGE-ATLASW-020: an empty atlas shows the mascot empty state instead of the stats', () => { + setAtlas({ data: buildAtlasData({ countries: [], lastTrip: null, continents: {} }) }); + render(); + + expect(screen.getByText('atlas.noData')).toBeInTheDocument(); + expect(screen.queryByText('atlas.lastTrip')).not.toBeInTheDocument(); + }); + + it('FE-PAGE-ATLASW-021: the country detail overlay lists trips and offers the manual unmark', () => { + const atlas = setAtlas({ + selectedCountry: 'FR', + countryDetail: buildCountryDetail({ + places: [{ id: 1 }, { id: 2 }, { id: 3 }] as unknown as CountryDetail['places'], + trips: [ + { id: 11, title: 'Paris' }, + { id: 12, title: 'Nice' }, + { id: 13, title: 'Lyon' }, + { id: 14, title: 'Brest' }, + ], + manually_marked: true, + }), + }); + render(); + + expect(screen.getByText('France')).toBeInTheDocument(); + expect(screen.getByText('3 atlas.places Β· 4 Trips')).toBeInTheDocument(); + // Only the first three trips are shown. + expect(screen.queryByText('Brest')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Nice')); + expect(atlas.navigate).toHaveBeenCalledWith('/trips/12'); + + fireEvent.click(screen.getByText('atlas.unmark')); + expect(atlas.handleUnmarkCountry).toHaveBeenCalledWith('FR'); + }); + + it('FE-PAGE-ATLASW-022: the stats panel measures itself through a ResizeObserver', () => { + const observeSpy = vi.fn(); + const disconnectSpy = vi.fn(); + vi.stubGlobal( + 'ResizeObserver', + class { + observe = observeSpy; + unobserve = vi.fn(); + disconnect = disconnectSpy; + constructor(cb: ResizeObserverCallback) { + cb([], this as unknown as ResizeObserver); + } + }, + ); + const { unmount } = render(); + + expect(observeSpy).toHaveBeenCalled(); + unmount(); + expect(disconnectSpy).toHaveBeenCalled(); + }); + + it('FE-PAGE-ATLASW-023: the bucket tab renders items with target month, notes and a delete action', () => { + const atlas = setAtlas({ + bucketTab: 'bucket', + bucketList: [ + buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JPN', target_date: '2027-04' }), + buildBucketItem({ id: 2, name: 'Patagonia', country_code: null, notes: 'road trip' }), + buildBucketItem({ id: 3, name: 'Lisbon', country_code: 'PT', target_date: '2030' }), + ], + }); + render(); + + expect(screen.getByText('Kyoto')).toBeInTheDocument(); + expect(screen.getByText('Apr 2027')).toBeInTheDocument(); + expect(screen.getByText('road trip')).toBeInTheDocument(); + expect(screen.getByText('2030')).toBeInTheDocument(); + expect((document.querySelector('img[alt="JP"]') as HTMLImageElement).src).toContain('flagcdn.com/w40/jp.png'); + + const deleteBtn = screen.getByText('Patagonia').closest('div')?.querySelector('button') as HTMLButtonElement; + fireEvent.click(deleteBtn); + expect(atlas.handleDeleteBucketItem).toHaveBeenCalledWith(2); + + fireEvent.click(screen.getByText('atlas.addPoi')); + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(true); + }); + + it('FE-PAGE-ATLASW-024: the bucket add form searches, picks a result and submits', () => { + const atlas = setAtlas({ + bucketTab: 'bucket', + showBucketAdd: true, + bucketSearch: 'kyo', + bucketSearchResults: [{ name: 'Kyoto', address: 'Japan' }], + }); + render(); + + const nameInput = screen.getByPlaceholderText('atlas.bucketNamePlaceholder'); + expect(nameInput).toHaveValue('kyo'); + fireEvent.change(nameInput, { target: { value: 'kyot' } }); + expect(atlas.setBucketSearch).toHaveBeenCalledWith('kyot'); + + fireEvent.keyDown(nameInput, { key: 'Enter' }); + expect(atlas.handleBucketPoiSearch).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('Kyoto')); + expect(atlas.handleSelectBucketPoi).toHaveBeenCalledWith({ name: 'Kyoto', address: 'Japan' }); + + const [monthTrigger, yearTrigger] = selectTriggers(); + fireEvent.click(monthTrigger); + fireEvent.click(screen.getByText('Mar')); + expect(atlas.setBucketPoiMonth).toHaveBeenCalledWith(3); + + const nextYear = String(new Date().getFullYear() + 2); + fireEvent.click(yearTrigger); + fireEvent.click(screen.getByText(nextYear)); + expect(atlas.setBucketPoiYear).toHaveBeenCalledWith(Number(nextYear)); + + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false); + expect(atlas.setBucketForm).toHaveBeenCalledWith({ name: '', notes: '', lat: '', lng: '', target_date: '' }); + }); + + it('FE-PAGE-ATLASW-025: a picked place shows its coordinates and can be cleared again', () => { + const atlas = setAtlas({ + bucketTab: 'bucket', + showBucketAdd: true, + bucketForm: { name: 'Kyoto', notes: '', lat: '35.0116', lng: '135.7681', target_date: '' }, + }); + render(); + + expect(screen.getByText(/35\.0116, 135\.7681/)).toBeInTheDocument(); + + const nameInput = screen.getByPlaceholderText('atlas.bucketNamePlaceholder'); + fireEvent.change(nameInput, { target: { value: 'Kyoto old town' } }); + expect(atlas.setBucketForm).toHaveBeenCalledWith({ + name: 'Kyoto old town', notes: '', lat: '35.0116', lng: '135.7681', target_date: '', + }); + + fireEvent.keyDown(nameInput, { key: 'Enter' }); + expect(atlas.handleAddBucketItem).toHaveBeenCalled(); + fireEvent.keyDown(nameInput, { key: 'Escape' }); + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false); + + const clearBtn = nameInput.parentElement?.querySelector('button') as HTMLButtonElement; + fireEvent.click(clearBtn); + expect(atlas.setBucketForm).toHaveBeenLastCalledWith({ + name: '', notes: '', lat: '', lng: '', target_date: '', + }); + + fireEvent.click(screen.getByText('common.add')); + expect(atlas.handleAddBucketItem).toHaveBeenCalledTimes(2); + }); + + it('FE-PAGE-ATLASW-026: the tab bar switches between stats and bucket list', () => { + const atlas = setAtlas({ bucketList: [] }); + render(); + + expect(screen.getByText('atlas.bucketEmptyHint')).toBeInTheDocument(); + fireEvent.click(screen.getByText('atlas.bucketTab')); + expect(atlas.setBucketTab).toHaveBeenCalledWith('bucket'); + fireEvent.click(screen.getByText('atlas.statsTab')); + expect(atlas.setBucketTab).toHaveBeenCalledWith('stats'); + }); + }); + + describe('appearance', () => { + afterEach(() => { + document.documentElement.removeAttribute('data-no-transparency'); + }); + + it('FE-PAGE-ATLASW-027: the no-transparency setting paints the tooltip and the panel opaque', () => { + document.documentElement.setAttribute('data-no-transparency', ''); + const atlas = setAtlas(); + const { unmount } = render(); + + const tooltip = atlas.regionTooltipRef.current as HTMLElement; + const panel = atlas.panelRef.current as HTMLElement; + expect(tooltip.style.background).toBe('rgb(255, 255, 255)'); + expect(panel.style.background).toBe('rgb(255, 255, 255)'); + unmount(); + + const darkAtlas = setAtlas({ dark: true }); + render(); + expect((darkAtlas.regionTooltipRef.current as HTMLElement).style.background).toBe('rgb(15, 15, 20)'); + expect((darkAtlas.panelRef.current as HTMLElement).style.background).toBe('rgb(21, 21, 28)'); + }); + + it('FE-PAGE-ATLASW-028: without the setting both surfaces stay translucent', () => { + const atlas = setAtlas(); + render(); + + expect((atlas.regionTooltipRef.current as HTMLElement).style.background).toBe('rgba(255, 255, 255, 0.96)'); + expect((atlas.panelRef.current as HTMLElement).style.background).toBe('rgba(255, 255, 255, 0.2)'); + }); + }); + + describe('continent bookkeeping', () => { + it('FE-PAGE-ATLASW-029: marking a region on a new continent starts that continent at one', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'choose-region', code: 'JP', name: 'Kansai', regionCode: 'JP-27' }, + }); + render(); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + const data = atlas.data as AtlasData; + // Europe was the only known continent; Asia is added from zero. + expect(data.continents).toEqual({ Europe: 1, Asia: 1 }); + expect(data.stats.totalCountries).toBe(2); + }); + + it('FE-PAGE-ATLASW-030: removing a country that has no recorded regions clamps its continent at zero', async () => { + const atlas = setAtlas({ + confirmAction: { type: 'unmark-region', code: 'JP', name: 'Kansai', regionCode: 'JP-27' }, + visitedRegions: {} as VisitedRegions, + data: buildAtlasData({ + countries: [{ code: 'JP', tripCount: 0, placeCount: 0, firstVisit: null, lastVisit: null }], + stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 }, + continents: { Europe: 1 }, + }), + }); + render(); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + const data = atlas.data as AtlasData; + expect(data.countries).toHaveLength(0); + expect(data.stats.totalCountries).toBe(0); + // Asia was never counted, so the decrement floors at 0 instead of going negative. + expect(data.continents).toEqual({ Europe: 1, Asia: 0 }); + }); + + it('FE-PAGE-ATLASW-031: backing out of the bucket step returns to the country chooser', () => { + const atlas = setAtlas({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + render(); + + fireEvent.click(screen.getByText('common.back')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ type: 'choose', code: 'DE', name: 'Germany' }); + }); + }); +}); diff --git a/client/src/pages/CollectionsPage.wiring.test.tsx b/client/src/pages/CollectionsPage.wiring.test.tsx new file mode 100644 index 000000000..a13f395e0 --- /dev/null +++ b/client/src/pages/CollectionsPage.wiring.test.tsx @@ -0,0 +1,754 @@ +// FE-PAGE-COLLPAGE-001 to FE-PAGE-COLLPAGE-030 +// Structural coverage for the desktop Collections page: which branch of the body +// renders, which affordances a role gets, and that every child callback is wired +// to the hook. The page follows the Page pattern, so useCollections is mocked and +// each child is stubbed with a prop-driven button. +import React from 'react' +import { render, screen, fireEvent } from '../../tests/helpers/render' +import CollectionsPage from './CollectionsPage' + +vi.mock('../components/Layout/Navbar', () => ({ default: () =>