diff --git a/client/tests/helpers/mobileAdmin.ts b/client/tests/helpers/mobileAdmin.ts new file mode 100644 index 000000000..20b5c69f1 --- /dev/null +++ b/client/tests/helpers/mobileAdmin.ts @@ -0,0 +1,174 @@ +import { vi } from 'vitest'; +import type { useAdmin } from '../../src/pages/admin/useAdmin'; +import type { AdminUser } from '../../src/pages/admin/adminModel'; + +/** + * Fixture for the mobile admin sections. MAdminUsersSection, MAdminSettingsSection + * and MAdminSheets are pure presentation over the useAdmin() bag, so tests render + * them with this builder instead of mounting MAdmin and its data loading. + * + * Defaults are the "nothing configured yet" state; every setter and handler is a + * spy so tests can assert the exact call, including state updater functions. + */ + +export type AdminHook = ReturnType; + +export function buildAdminUser(overrides: Partial = {}): AdminUser { + return { + id: 1, + username: 'admin', + email: 'admin@example.com', + role: 'admin', + created_at: '2025-01-01T00:00:00.000Z', + avatar_url: null, + ...overrides, + }; +} + +export function buildAdminToast(): Record> { + return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +} + +export function buildAdminHook(overrides: Record = {}): AdminHook { + const base: Record = { + // store-derived + demoMode: false, + serverTimezone: 'UTC', + hour12: false, + mcpEnabled: false, + devMode: false, + currentUser: buildAdminUser(), + updateApiKeys: vi.fn(async () => undefined), + setAppRequireMfa: vi.fn(), + setTripRemindersEnabled: vi.fn(), + setPlacesPhotosEnabled: vi.fn(), + setPlacesAutocompleteEnabled: vi.fn(), + setPlacesDetailsEnabled: vi.fn(), + logout: vi.fn(async () => undefined), + navigate: vi.fn(), + toast: buildAdminToast(), + + // users + activeTab: 'users', + setActiveTab: vi.fn(), + users: [] as AdminUser[], + setUsers: vi.fn(), + stats: null, + isLoading: false, + editingUser: null, + setEditingUser: vi.fn(), + editForm: { username: '', email: '', role: 'user', password: '' }, + setEditForm: vi.fn(), + showCreateUser: false, + setShowCreateUser: vi.fn(), + createForm: { username: '', email: '', password: '', role: 'user' }, + setCreateForm: vi.fn(), + + // feature flags + bagTrackingEnabled: false, + setBagTrackingEnabled: vi.fn(), + placesPhotosEnabled: false, + setPlacesPhotosEnabledState: vi.fn(), + placesAutocompleteEnabled: false, + setPlacesAutocompleteEnabledState: vi.fn(), + placesDetailsEnabled: false, + setPlacesDetailsEnabledState: vi.fn(), + collabFeatures: { chat: true, notes: true, polls: true, whatsnext: true }, + setCollabFeatures: vi.fn(), + + // auth / OIDC + oidcConfig: { + issuer: '', + client_id: '', + client_secret: '', + client_secret_set: false, + display_name: '', + discovery_url: '', + }, + setOidcConfig: vi.fn(), + savingOidc: false, + setSavingOidc: vi.fn(), + passwordLogin: true, + setPasswordLogin: vi.fn(), + passwordRegistration: true, + setPasswordRegistration: vi.fn(), + oidcLogin: false, + setOidcLogin: vi.fn(), + oidcRegistration: false, + setOidcRegistration: vi.fn(), + envOverrideOidcOnly: false, + setEnvOverrideOidcOnly: vi.fn(), + oidcConfigured: false, + setOidcConfigured: vi.fn(), + requireMfa: false, + setRequireMfa: vi.fn(), + passkeyLogin: false, + setPasskeyLogin: vi.fn(), + passkeyConfigured: false, + webauthnRpId: '', + setWebauthnRpId: vi.fn(), + webauthnOrigins: '', + setWebauthnOrigins: vi.fn(), + savingWebauthn: false, + handleSaveWebauthn: vi.fn(), + + // invites + invites: [] as unknown[], + setInvites: vi.fn(), + inviteTrips: [] as { id: number; title: string }[], + showCreateInvite: false, + setShowCreateInvite: vi.fn(), + inviteForm: { max_uses: 1, expires_in_days: 7, trip_id: '' }, + setInviteForm: vi.fn(), + + // misc settings + allowedFileTypes: 'jpg,png,pdf', + setAllowedFileTypes: vi.fn(), + savingFileTypes: false, + setSavingFileTypes: vi.fn(), + smtpValues: {}, + setSmtpValues: vi.fn(), + smtpLoaded: true, + mapsKey: '', + setMapsKey: vi.fn(), + weatherKey: '', + setWeatherKey: vi.fn(), + unsplashKey: '', + setUnsplashKey: vi.fn(), + showKeys: {}, + setShowKeys: vi.fn(), + savingKeys: false, + validating: {}, + validation: {}, + + // update / danger zone + updateInfo: null, + setUpdateInfo: vi.fn(), + showUpdateModal: false, + setShowUpdateModal: vi.fn(), + showRotateJwtModal: false, + setShowRotateJwtModal: vi.fn(), + rotatingJwt: false, + setRotatingJwt: vi.fn(), + + // handlers + loadData: vi.fn(), + loadAppConfig: vi.fn(), + loadApiKeys: vi.fn(), + handleToggleAuthSetting: vi.fn(), + handleToggleRequireMfa: vi.fn(), + toggleKey: vi.fn(), + handleSaveApiKeys: vi.fn(), + handleValidateKeys: vi.fn(), + handleValidateKey: vi.fn(), + handleCreateUser: vi.fn(), + handleCreateInvite: vi.fn(), + handleDeleteInvite: vi.fn(), + copyInviteLink: vi.fn(), + handleEditUser: vi.fn(), + handleSaveUser: vi.fn(), + handleDeleteUser: vi.fn(), + }; + + return { ...base, ...overrides } as unknown as AdminHook; +} diff --git a/client/tests/unit/mobile/MBottomNav.test.tsx b/client/tests/unit/mobile/MBottomNav.test.tsx index 1ce47efc5..f9a458f43 100644 --- a/client/tests/unit/mobile/MBottomNav.test.tsx +++ b/client/tests/unit/mobile/MBottomNav.test.tsx @@ -1,10 +1,21 @@ import React from 'react'; import { describe, it, expect, beforeEach } from 'vitest'; +import { useLocation } from 'react-router-dom'; import { render, screen, fireEvent } from '../../helpers/render'; import MBottomNav from '../../../src/mobile/components/MBottomNav'; import { useAddonStore } from '../../../src/store/addonStore'; import { usePluginStore } from '../../../src/store/pluginStore'; +function LocationEcho() { + const location = useLocation(); + return {location.pathname + location.search}; +} + +const nav = (entry: string) => + render(<>, { initialEntries: [entry] }); + +const at = () => screen.getByTestId('loc').textContent; + // FE-MOB-NAV-001 onwards function seedAddons(ids: string[]) { @@ -81,4 +92,92 @@ describe('MBottomNav', () => { render(, { initialEntries: ['/settings'] }); expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument(); }); + + it('FE-MOB-NAV-010: admin also swaps the "+" for the logo slot', () => { + render(, { initialEntries: ['/admin'] }); + expect(screen.queryByRole('button', { name: 'New Trip' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-NAV-011: choosing a More entry navigates and closes the popover', () => { + seedAddons(['vacay', 'atlas', 'journey', 'collections']); + render(, { initialEntries: ['/dashboard'] }); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + fireEvent.click(screen.getByRole('button', { name: /Journey/ })); + + const more = screen.getByRole('button', { name: 'More' }); + expect(more).toHaveAttribute('aria-expanded', 'false'); + expect(more).toHaveAttribute('aria-current', 'page'); + }); + + it('FE-MOB-NAV-012: tapping the scrim closes the More popover again', () => { + seedAddons(['vacay', 'atlas', 'journey', 'collections']); + const { container } = render(, { initialEntries: ['/dashboard'] }); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + const scrim = container.querySelector('.fixed.inset-0') as HTMLElement; + + fireEvent.click(scrim); + + expect(screen.getByRole('button', { name: 'More' })).toHaveAttribute('aria-expanded', 'false'); + expect(screen.queryByRole('button', { name: /Collections/ })).not.toBeInTheDocument(); + }); + + it('FE-MOB-NAV-013: a click inside the popover does not close it', () => { + seedAddons(['vacay', 'atlas', 'journey', 'collections']); + const { container } = render(, { initialEntries: ['/dashboard'] }); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + fireEvent.click(container.querySelector('.fixed.inset-0 > div') as HTMLElement); + + expect(screen.getByRole('button', { name: /Journey/ })).toBeInTheDocument(); + }); + + it.each([ + ['/journey', 'New Journey', '/journey?create=1'], + ['/journey/4', 'Add Entry', '/journey/4?create=entry'], + ['/collections', 'Add a place', '/collections?create=place'], + ['/trips/7', 'Add Place/Activity', '/trips/7?create=place'], + ['/dashboard', 'New Trip', '/dashboard?create=1'], + ['/atlas', 'Search a country...', '/atlas?search=1'], + ])('FE-MOB-NAV-014: the "+" on %s runs "%s"', (route, label, target) => { + seedAddons(['journey', 'collections', 'atlas']); + nav(route); + + fireEvent.click(screen.getByRole('button', { name: label })); + + expect(at()).toBe(target); + }); + + it.each([ + ['finanzplan', 'Add expense', '/trips/7?create=expense'], + ['transports', 'Transport', '/trips/7?create=transport'], + ['buchungen', 'Manual Booking', '/trips/7?create=reservation'], + ])('FE-MOB-NAV-015: the trip "+" follows the %s tab', (tab, label, target) => { + sessionStorage.setItem('trip-tab-7', tab); + nav('/trips/7'); + + fireEvent.click(screen.getByRole('button', { name: label })); + + expect(at()).toBe(target); + }); + + it('FE-MOB-NAV-016: a dock tab navigates to its route', () => { + seedAddons(['vacay', 'atlas']); + nav('/dashboard'); + + fireEvent.click(screen.getByRole('button', { name: 'Atlas' })); + + expect(at()).toBe('/atlas'); + }); + + it('FE-MOB-NAV-017: a More entry navigates to its route', () => { + seedAddons(['vacay', 'atlas', 'journey', 'collections']); + nav('/dashboard'); + + fireEvent.click(screen.getByRole('button', { name: 'More' })); + fireEvent.click(screen.getByRole('button', { name: /Collections/ })); + + expect(at()).toBe('/collections'); + }); }); diff --git a/client/tests/unit/mobile/MDancingTrek.test.tsx b/client/tests/unit/mobile/MDancingTrek.test.tsx new file mode 100644 index 000000000..ba02e5d8b --- /dev/null +++ b/client/tests/unit/mobile/MDancingTrek.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, fireEvent } from '../../helpers/render'; +import MDancingTrek, { type TrekScene } from '../../../src/mobile/components/MDancingTrek'; + +// FE-MOB-TREKM-001 onwards + +const svgOf = (container: HTMLElement) => container.querySelector('svg') as SVGSVGElement; + +describe('MDancingTrek', () => { + it('FE-MOB-TREKM-001: the idle mascot keeps its aspect ratio and open eyes', () => { + const { container } = render(); + const svg = svgOf(container); + + expect(svg).toHaveAttribute('width', '88'); + expect(svg).toHaveAttribute('height', '96'); + expect(svg.getAttribute('class')).toContain('trek--idle'); + expect(svg.getAttribute('class')).toContain('mb-2'); + expect(container.querySelectorAll('.trek-eye')).toHaveLength(2); + expect(container.querySelector('.trek-pupils')).toBeInTheDocument(); + }); + + it('FE-MOB-TREKM-002: the ground shadow drops for the skateboard scene', () => { + const { container } = render(); + expect(container.querySelector('.trek-shadow')).toHaveAttribute('cy', '86'); + + const { container: idle } = render(); + expect(idle.querySelector('.trek-shadow')).toHaveAttribute('cy', '73'); + }); + + it.each([ + ['happy', 2, 0], + ['sleepy', 2, 0], + ['error', 2, 0], + ] as const)('FE-MOB-TREKM-003: the %s mood swaps the eyes for drawn strokes', (mood, paths, eyes) => { + const { container } = render(); + + expect(container.querySelectorAll('.trek-body g[stroke] path')).toHaveLength(paths); + expect(container.querySelectorAll('.trek-eye')).toHaveLength(eyes); + }); + + it('FE-MOB-TREKM-004: the confused mood keeps one open eye', () => { + const { container } = render(); + + expect(container.querySelectorAll('.trek-eye')).toHaveLength(1); + expect(container.querySelector('.trek-pupils')).toBeNull(); + }); + + it('FE-MOB-TREKM-005: a scene carries its own default mood', () => { + const { container: sleepy } = render(); + // sleepy = two drawn arcs, no open eyes + expect(sleepy.querySelectorAll('.trek-eye')).toHaveLength(0); + + const { container: confused } = render(); + expect(confused.querySelectorAll('.trek-eye')).toHaveLength(1); + }); + + it('FE-MOB-TREKM-006: an explicit mood beats the scene default', () => { + const { container } = render(); + + expect(container.querySelectorAll('.trek-eye')).toHaveLength(2); + }); + + it.each([ + ['transport', '.trek-board'], + ['guide', '.trek-guide'], + ['packing', '.trek-suitcase'], + ['polls', '.trek-polls'], + ['collections', '.trek-pin'], + ['atlas', '.trek-globe-wrap'], + ['costs', '.trek-coins'], + ['chat', '.trek-chat'], + ['bookings', '.trek-ticket'], + ['files', '.trek-ticket'], + ['notes', '.trek-note'], + ['journey', '.trek-journal'], + ['dashboard', '.trek-plane'], + ['notifications', '.trek-zzz'], + ['search', '.trek-magnifier'], + ['tasks', '.trek-tasks'], + ] as Array<[TrekScene, string]>)('FE-MOB-TREKM-007: the %s scene brings its own prop', (scene, selector) => { + const { container } = render(); + + expect(container.querySelector(selector)).toBeInTheDocument(); + expect(container.querySelector(`.trek-bounce--${scene}`)).toBeInTheDocument(); + }); + + it('FE-MOB-TREKM-008: the idle scene has no prop at all', () => { + const { container } = render(); + + expect(container.querySelector('.trek-root')?.children).toHaveLength(1); + }); + + it('FE-MOB-TREKM-009: poking the mascot remounts the svg to replay the pop-in', () => { + const { container } = render(); + const before = svgOf(container); + + fireEvent.click(before); + + expect(svgOf(container)).not.toBe(before); + }); +}); diff --git a/client/tests/unit/mobile/MSheet.test.tsx b/client/tests/unit/mobile/MSheet.test.tsx index 168b87a28..4c22d4704 100644 --- a/client/tests/unit/mobile/MSheet.test.tsx +++ b/client/tests/unit/mobile/MSheet.test.tsx @@ -79,6 +79,161 @@ describe('MSheet', () => { expect(screen.queryByText('Sheet content')).not.toBeInTheDocument(); }); + it('FE-MOB-SHEET-007: Tab wraps around inside the dialog', () => { + render( + {}} ariaLabel="Trap"> + + + , + ); + const dialog = screen.getByRole('dialog'); + const first = screen.getByText('first'); + const last = screen.getByText('last'); + + last.focus(); + fireEvent.keyDown(dialog, { key: 'Tab' }); + expect(document.activeElement).toBe(first); + + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + expect(document.activeElement).toBe(last); + }); + + it('FE-MOB-SHEET-008: shift+Tab from the panel itself jumps to the last control', () => { + render( + {}} ariaLabel="Trap"> + + + , + ); + const dialog = screen.getByRole('dialog'); + dialog.focus(); + + fireEvent.keyDown(dialog, { key: 'Tab', shiftKey: true }); + + expect(document.activeElement).toBe(screen.getByText('last')); + }); + + it('FE-MOB-SHEET-009: keys other than Tab pass through', () => { + render( + {}} ariaLabel="Trap"> + + , + ); + const dialog = screen.getByRole('dialog'); + const first = screen.getByText('first'); + first.focus(); + + fireEvent.keyDown(dialog, { key: 'a' }); + + expect(document.activeElement).toBe(first); + }); + + it('FE-MOB-SHEET-010: a sheet without focusable content swallows Tab', () => { + render( + {}} ariaLabel="Empty"> + nothing to focus + , + ); + const dialog = screen.getByRole('dialog'); + + const handled = fireEvent.keyDown(dialog, { key: 'Tab' }); + + // fireEvent returns false once a listener called preventDefault. + expect(handled).toBe(false); + }); + + it('FE-MOB-SHEET-011: the entrance animation class is dropped once it has played', () => { + render( + {}} ariaLabel="Anim"> + Sheet content + , + ); + const dialog = screen.getByRole('dialog'); + expect(dialog.className).toContain('m-sheet-in'); + + // jsdom has no AnimationEvent, so React binds the vendor-prefixed name. + act(() => { + dialog.dispatchEvent(new Event('animationend', { bubbles: true })); + dialog.dispatchEvent(new Event('webkitAnimationEnd', { bubbles: true })); + }); + + expect(dialog.className).not.toContain('m-sheet-in'); + }); + + it('FE-MOB-SHEET-012: dragging the bottom sheet far enough dismisses it', () => { + HTMLElement.prototype.setPointerCapture = vi.fn(); + const onClose = vi.fn(); + render( + + Sheet content + , + ); + const dialog = screen.getByRole('dialog'); + Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); + const strip = dialog.querySelector('.cursor-grab') as HTMLElement; + + fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); + fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260 }); + expect(dialog.getAttribute('style')).toContain('translateY(160px)'); + // A slow last move keeps the velocity low, so only the 30% travel rule can close it. + fireEvent.pointerMove(strip, { pointerId: 1, clientY: 260.3 }); + + fireEvent.pointerUp(strip, { pointerId: 1, clientY: 260.3 }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SHEET-015: a quick flick dismisses even without much travel', () => { + HTMLElement.prototype.setPointerCapture = vi.fn(); + const onClose = vi.fn(); + render( + + Sheet content + , + ); + const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; + + fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); + fireEvent.pointerMove(strip, { pointerId: 1, clientY: 130 }); + fireEvent.pointerUp(strip, { pointerId: 1, clientY: 130 }); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SHEET-013: a short drag snaps back instead of closing', () => { + HTMLElement.prototype.setPointerCapture = vi.fn(); + const onClose = vi.fn(); + render( + + Sheet content + , + ); + const dialog = screen.getByRole('dialog'); + Object.defineProperty(dialog, 'offsetHeight', { configurable: true, value: 400 }); + const strip = dialog.querySelector('.cursor-grab') as HTMLElement; + + fireEvent.pointerDown(strip, { pointerId: 1, clientY: 100 }); + fireEvent.pointerMove(strip, { pointerId: 1, clientY: 100.4 }); + fireEvent.pointerCancel(strip, { pointerId: 1, clientY: 100.4 }); + + expect(onClose).not.toHaveBeenCalled(); + expect(dialog.getAttribute('style')).toContain('transform 280ms'); + }); + + it('FE-MOB-SHEET-014: pointer moves without a drag in progress are ignored', () => { + const onClose = vi.fn(); + render( + + Sheet content + , + ); + const strip = screen.getByRole('dialog').querySelector('.cursor-grab') as HTMLElement; + + fireEvent.pointerMove(strip, { pointerId: 1, clientY: 400 }); + fireEvent.pointerUp(strip, { pointerId: 1, clientY: 400 }); + + expect(onClose).not.toHaveBeenCalled(); + }); + it('FE-MOB-SHEET-006: locks body scroll while open and releases it on close', () => { const { rerender } = render( {}}> diff --git a/client/tests/unit/mobile/MToastHost.test.tsx b/client/tests/unit/mobile/MToastHost.test.tsx new file mode 100644 index 000000000..5554b9cf3 --- /dev/null +++ b/client/tests/unit/mobile/MToastHost.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import MToastHost from '../../../src/mobile/components/MToastHost'; + +// FE-MOB-MTOAST-001 onwards + +function push(message: string, type?: 'success' | 'error' | 'warning' | 'info', duration?: number): void { + act(() => { window.__addToast?.(message, type, duration); }); +} + +function pill(message: string): HTMLElement { + return screen.getByText(message).parentElement as HTMLElement; +} + +afterEach(() => { + vi.useRealTimers(); + delete window.__addToast; +}); + +describe('MToastHost', () => { + it('FE-MOB-MTOAST-001: renders nothing until a toast arrives', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + + push('Trip saved'); + expect(screen.getByText('Trip saved')).toBeInTheDocument(); + }); + + it('FE-MOB-MTOAST-002: takes over the global bridge and hands it back on unmount', () => { + const previous = vi.fn(() => 0); + window.__addToast = previous; + + const { unmount } = render(); + expect(window.__addToast).not.toBe(previous); + + unmount(); + expect(window.__addToast).toBe(previous); + }); + + it('FE-MOB-MTOAST-003: stacks several toasts and returns rising ids', () => { + render(); + let first = 0; + let second = 0; + act(() => { + first = window.__addToast?.('First') ?? 0; + second = window.__addToast?.('Second') ?? 0; + }); + + expect(second).toBe(first + 1); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + }); + + it('FE-MOB-MTOAST-004: auto-dismisses after its duration plus the exit animation', () => { + vi.useFakeTimers(); + render(); + push('Trip saved', 'info', 1000); + + act(() => { vi.advanceTimersByTime(1000); }); + // Still on screen, now playing the exit animation. + expect(pill('Trip saved').className).toContain('m-toast-out'); + + act(() => { vi.advanceTimersByTime(220); }); + expect(screen.queryByText('Trip saved')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MTOAST-005: a sticky toast waits for a tap', () => { + vi.useFakeTimers(); + render(); + push('Offline', 'warning', 0); + + act(() => { vi.advanceTimersByTime(10000); }); + expect(screen.getByText('Offline')).toBeInTheDocument(); + expect(pill('Offline').className).toContain('pointer-events-auto'); + + fireEvent.click(pill('Offline')); + act(() => { vi.advanceTimersByTime(220); }); + expect(screen.queryByText('Offline')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MTOAST-006: only the non-info types get a status dot', () => { + render(); + push('Done', 'success'); + push('Heads up', 'info'); + + expect(pill('Done').querySelector('span[aria-hidden]')).toBeInTheDocument(); + expect(pill('Heads up').querySelector('span[aria-hidden]')).toBeNull(); + }); + + it('FE-MOB-MTOAST-007: pending timers are cleared when the host unmounts', () => { + vi.useFakeTimers(); + const { unmount } = render(); + push('Trip saved', 'info', 1000); + + unmount(); + // Without the cleanup the pending dismiss would setState on an unmounted host. + expect(() => act(() => { vi.advanceTimersByTime(2000); })).not.toThrow(); + }); +}); diff --git a/client/tests/unit/mobile/MobileShell.test.tsx b/client/tests/unit/mobile/MobileShell.test.tsx new file mode 100644 index 000000000..adcaa5350 --- /dev/null +++ b/client/tests/unit/mobile/MobileShell.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, act } from '../../helpers/render'; +import MobileShell from '../../../src/mobile/MobileShell'; +import { useAddonStore } from '../../../src/store/addonStore'; +import { usePluginStore } from '../../../src/store/pluginStore'; + +// FE-MOB-MSHELL-001 onwards + +beforeEach(() => { + useAddonStore.setState({ addons: [], loaded: true }); + usePluginStore.setState({ plugins: [], loaded: true }); +}); + +describe('MobileShell', () => { + it('FE-MOB-MSHELL-001: keeps the legacy wrapper above the phone breakpoint', () => { + const { container } = render( +

page body

, + { initialEntries: ['/dashboard'] }, + ); + + expect(screen.getByText('page body')).toBeInTheDocument(); + expect(container.querySelector('.m-root')).toBeNull(); + expect(document.getElementById('m-sheet-root')).toBeNull(); + }); + + it('FE-MOB-MSHELL-002: scopes the mobile tokens and mounts the dock plus sheet portal', () => { + const { container } = render( +

page body

, + { initialEntries: ['/dashboard'] }, + ); + + expect(container.querySelector('.m-root')).toBeInTheDocument(); + expect(document.getElementById('m-sheet-root')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'My Trips' })).toBeInTheDocument(); + }); + + it('FE-MOB-MSHELL-003: hides the global dock inside the trip planner', () => { + render( +

page body

, + { initialEntries: ['/trips/7'] }, + ); + + expect(screen.queryByRole('button', { name: 'My Trips' })).not.toBeInTheDocument(); + expect(screen.getByText('page body')).toBeInTheDocument(); + }); + + it('FE-MOB-MSHELL-004: the mobile toast presenter takes over the global bridge', () => { + const previous = vi.fn(() => 0); + window.__addToast = previous; + + render(

page body

, { initialEntries: ['/dashboard'] }); + expect(window.__addToast).not.toBe(previous); + + act(() => { window.__addToast?.('Saved', 'success'); }); + expect(screen.getByText('Saved')).toBeInTheDocument(); + + delete window.__addToast; + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdmin.test.tsx b/client/tests/unit/mobile/admin/MAdmin.test.tsx index 2f65db18c..96b7285c8 100644 --- a/client/tests/unit/mobile/admin/MAdmin.test.tsx +++ b/client/tests/unit/mobile/admin/MAdmin.test.tsx @@ -1,9 +1,14 @@ import React from 'react'; -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { Route, Routes } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; import { resetAllStores, seedStore } from '../../../helpers/store'; import { buildAdmin } from '../../../helpers/factories'; import { useAuthStore } from '../../../../src/store/authStore'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; import MAdmin from '../../../../src/mobile/screens/admin/MAdmin'; // FE-MOB-ADMIN-001 onwards @@ -14,9 +19,99 @@ vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({ default: () =>
, })); +// Every other section panel has its own suite; here only the routing into them +// matters, so they are reduced to markers. +vi.mock('../../../../src/mobile/screens/admin/MAdminPluginsPanel', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminBackupPanel', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminCategoryManager', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminPackingTemplateManager', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminDefaultUserSettings', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminDevNotificationsPanel', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminNotificationsSection', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminMcpTokensPanel', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminAuditLogPanel', () => ({ + default: ({ serverTimezone }: { serverTimezone?: string }) => ( +
{serverTimezone}
+ ), +})); +vi.mock('../../../../src/mobile/screens/admin/MAdminGitHubPanel', () => ({ + default: ({ isPrerelease }: { isPrerelease: boolean }) => ( +
{String(isPrerelease)}
+ ), +})); + +// The addon manager is reduced to the two controls MAdmin wires itself. +vi.mock('../../../../src/mobile/screens/admin/MAdminAddonManager', () => ({ + default: ({ + bagTrackingEnabled, + onToggleBagTracking, + collabFeatures, + onToggleCollabFeature, + }: { + bagTrackingEnabled: boolean; + onToggleBagTracking: () => void; + collabFeatures: Record; + onToggleCollabFeature: (key: string) => void; + }) => ( +
+ {String(bagTrackingEnabled)} + + {String(collabFeatures.chat)} + +
+ ), +})); + +function renderAdmin() { + return render( + <> + + + } /> + dashboard page
} /> + + , + { initialEntries: ['/admin'] }, + ); +} + +async function openSection(label: string) { + fireEvent.click(screen.getByRole('button', { name: 'Administration' })); + fireEvent.click(screen.getByRole('button', { name: label })); +} + beforeEach(() => { resetAllStores(); seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + server.use( + http.get('/api/admin/collab-features', () => + HttpResponse.json({ chat: true, notes: true, polls: true, whatsnext: true }), + ), + http.get('/api/admin/places-photos', () => HttpResponse.json({ enabled: false })), + http.get('/api/admin/places-autocomplete', () => HttpResponse.json({ enabled: false })), + http.get('/api/admin/places-details', () => HttpResponse.json({ enabled: false })), + http.get('/api/admin/invites/trips', () => HttpResponse.json({ trips: [] })), + ); +}); + +afterEach(() => { + server.resetHandlers(); }); describe('MAdmin', () => { @@ -51,4 +146,227 @@ describe('MAdmin', () => { await waitFor(() => expect(screen.getByText('Authentication Methods')).toBeInTheDocument()); expect(screen.queryByText('alice')).not.toBeInTheDocument(); }); + + it('FE-MOB-ADMIN-003: the back button leaves for the dashboard', async () => { + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Back' })); + + expect(screen.getByText('dashboard page')).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-004: the header plus opens the create-user sheet', async () => { + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Create User' })); + + expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-005: MCP Access and the dev section stay hidden unless enabled', async () => { + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Administration' })); + + expect(screen.queryByRole('button', { name: 'MCP Access' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Dev: Notifications' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-006: the MCP addon adds its section to the dropdown', async () => { + useAddonStore.setState({ + addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: 'plug', enabled: true }], + bagTracking: false, + loaded: true, + }); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + await openSection('MCP Access'); + + expect(screen.getByTestId('mcp-tokens-panel')).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-007: dev mode adds the dev notifications section', async () => { + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), devMode: true }); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + await openSection('Dev: Notifications'); + + expect(screen.getByTestId('dev-notifications')).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-008: every remaining section is reachable from the dropdown', async () => { + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + await openSection('User Defaults'); + expect(screen.getByTestId('default-user-settings')).toBeInTheDocument(); + + await openSection('Personalization'); + expect(screen.getByTestId('packing-templates')).toBeInTheDocument(); + expect(screen.getByTestId('category-manager')).toBeInTheDocument(); + + await openSection('Plugins'); + expect(screen.getByTestId('plugins-panel')).toBeInTheDocument(); + + await openSection('Notifications'); + expect(screen.getByTestId('notifications-section')).toBeInTheDocument(); + + await openSection('Backup'); + expect(screen.getByTestId('backup-panel')).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-009: the audit and GitHub panels get the server timezone and prerelease flag', async () => { + server.use( + http.get('/api/admin/version-check', () => + HttpResponse.json({ + update_available: true, + latest: '3.5.0', + current: '3.4.0', + is_prerelease: true, + is_docker: true, + }), + ), + ); + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), serverTimezone: 'Europe/Berlin' }); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + await openSection('Audit'); + expect(screen.getByTestId('audit-panel')).toHaveTextContent('Europe/Berlin'); + + await openSection('GitHub'); + await waitFor(() => expect(screen.getByTestId('github-panel')).toHaveTextContent('true')); + }); + + it('FE-MOB-ADMIN-010: the update banner opens the how-to-update sheet', async () => { + server.use( + http.get('/api/admin/version-check', () => + HttpResponse.json({ update_available: true, latest: '3.5.0', current: '3.4.0', is_docker: true }), + ), + ); + renderAdmin(); + + await screen.findByText('Update available'); + expect(screen.getByText('Version v3.5.0 available · you have v3.4.0')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + + expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-011: no banner when the version check reports no update', async () => { + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + expect(screen.queryByText('Update available')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ADMIN-012: demo instances can save a baseline', 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 }); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' })); + + await screen.findByText('Baseline saved! Resets will restore to this state.'); + expect(saved).toBe(true); + }); + + it('FE-MOB-ADMIN-013: a failing baseline save surfaces the server message', async () => { + server.use( + http.post('/api/admin/save-demo-baseline', () => + HttpResponse.json({ error: 'Not a demo instance' }, { status: 400 }), + ), + ); + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin(), demoMode: true }); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Save Baseline' })); + + await screen.findByText('Not a demo instance'); + }); + + it('FE-MOB-ADMIN-014: the bag tracking toggle persists and rolls back on failure', async () => { + const bodies: unknown[] = []; + server.use( + http.put('/api/admin/bag-tracking', async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 + ? HttpResponse.json({ enabled: true }) + : HttpResponse.json({ error: 'boom' }, { status: 500 }); + }), + ); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + await openSection('Addons'); + + expect(screen.getByTestId('bag-state')).toHaveTextContent('false'); + fireEvent.click(screen.getByRole('button', { name: 'toggle bag' })); + await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true')); + + // Second call fails → the optimistic value is reverted + fireEvent.click(screen.getByRole('button', { name: 'toggle bag' })); + await waitFor(() => expect(screen.getByTestId('bag-state')).toHaveTextContent('true')); + expect(bodies).toEqual([{ enabled: true }, { enabled: false }]); + }); + + it('FE-MOB-ADMIN-015: a collab feature toggle sends only the changed key', async () => { + let sent: Record = {}; + server.use( + http.put('/api/admin/collab-features', async ({ request }) => { + sent = (await request.json()) as Record; + return HttpResponse.json({ ...sent }); + }), + ); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + await openSection('Addons'); + + await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true')); + fireEvent.click(screen.getByRole('button', { name: 'toggle chat' })); + + await waitFor(() => expect(sent).toEqual({ chat: false })); + expect(screen.getByTestId('chat-state')).toHaveTextContent('false'); + }); + + it('FE-MOB-ADMIN-016: a failing collab update restores the previous flags', async () => { + server.use( + http.put('/api/admin/collab-features', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderAdmin(); + await waitFor(() => expect(screen.getByText('alice')).toBeInTheDocument()); + await openSection('Addons'); + + await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true')); + fireEvent.click(screen.getByRole('button', { name: 'toggle chat' })); + + await waitFor(() => expect(screen.getByTestId('chat-state')).toHaveTextContent('true')); + }); + + it('FE-MOB-ADMIN-017: the stats grid falls back to zero files', async () => { + server.use( + http.get('/api/admin/stats', () => + HttpResponse.json({ totalUsers: 3, totalTrips: 7, totalPlaces: 11 }), + ), + ); + renderAdmin(); + + await screen.findByText('11'); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('0')).toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx new file mode 100644 index 000000000..f290ea93f --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminAddonManager.test.tsx @@ -0,0 +1,482 @@ +// FE-MOB-AADD-001 to FE-MOB-AADD-023 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { delay, http, HttpResponse } from 'msw'; +import { render, screen, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminAddonManager from '../../../../src/mobile/screens/admin/MAdminAddonManager'; + +interface AddonFixture { + id: string; + name: string; + description: string; + icon: string; + type: string; + enabled: boolean; + config?: Record; +} + +function buildAddon(overrides: Partial = {}): AddonFixture { + return { + id: 'todo', + name: 'Todo List', + description: 'Track tasks', + icon: 'ListChecks', + type: 'trip', + enabled: false, + ...overrides, + }; +} + +function addonsRoute(addons: AddonFixture[]) { + return http.get('/api/admin/addons', () => HttpResponse.json({ addons })); +} + +function llmAddon(config: Record = {}): AddonFixture { + return buildAddon({ + id: 'llm_parsing', + name: 'AI Parsing', + description: 'Extract bookings from files', + icon: 'Sparkles', + type: 'integration', + enabled: true, + config, + }); +} + +function modelsRoute(names: string[], seen?: (string | null)[]) { + return http.get('/api/admin/llm/local/models', ({ request }) => { + seen?.push(new URL(request.url).searchParams.get('baseUrl')); + return HttpResponse.json({ models: names.map(name => ({ name, size: 1 })) }); + }); +} + +let loadAddonsSpy: ReturnType; + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) }); + loadAddonsSpy = vi.spyOn(useAddonStore.getState(), 'loadAddons').mockResolvedValue(undefined); + server.use(addonsRoute([])); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('MAdminAddonManager', () => { + it('FE-MOB-AADD-001: shows the spinner while addons load', async () => { + server.use( + http.get('/api/admin/addons', async () => { + await delay(60); + return HttpResponse.json({ addons: [] }); + }), + ); + render(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + await screen.findByText('No addons available'); + expect(document.querySelector('.animate-spin')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AADD-002: the header uses the light wordmark in light mode', async () => { + render(); + + await screen.findByText('No addons available'); + expect(screen.getByText('Addons')).toBeInTheDocument(); + expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-dark.svg'); + }); + + it('FE-MOB-AADD-003: dark mode and auto+prefers-dark swap the wordmark', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) }); + const { unmount } = render(); + await screen.findByText('No addons available'); + expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'); + unmount(); + + const matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal('matchMedia', matchMedia); + seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'auto' }) }); + render(); + await screen.findByText('No addons available'); + expect(screen.getByAltText('TREK')).toHaveAttribute('src', '/text-light.svg'); + expect(matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)'); + vi.unstubAllGlobals(); + }); + + it('FE-MOB-AADD-004: a failing load toasts the addon error', async () => { + server.use(http.get('/api/admin/addons', () => HttpResponse.error())); + render(<>); + + await screen.findByText('Failed to update addon'); + expect(screen.getByText('No addons available')).toBeInTheDocument(); + }); + + it('FE-MOB-AADD-005: trip addons render with the section band, catalog label and type badge', async () => { + server.use(addonsRoute([buildAddon({ id: 'packing', name: 'Packing', enabled: true })])); + render(); + + await screen.findByText('Lists'); + expect(screen.getByText('Trip — Available as a tab within each trip')).toBeInTheDocument(); + expect(screen.getByText('Packing lists and to-do tasks for your trips')).toBeInTheDocument(); + expect(screen.getByText('Trip')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Lists' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-AADD-006: an addon without a catalog entry falls back to the API name', async () => { + server.use(addonsRoute([buildAddon({ id: 'mystery', name: 'Mystery Addon', description: 'No catalog key', icon: 'NoSuchIcon' })])); + render(); + + await screen.findByText('Mystery Addon'); + expect(screen.getByText('No catalog key')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Mystery Addon' })).toBeInTheDocument(); + }); + + it('FE-MOB-AADD-007: photo-flavoured trip addons are hidden from the trip section', async () => { + server.use( + addonsRoute([ + buildAddon({ id: 'photos', name: 'Memories', icon: 'Image' }), + buildAddon({ id: 'gallery', name: 'Trip Photos', icon: 'Puzzle', description: 'Share your photo stream' }), + buildAddon({ id: 'todo', name: 'Todo List' }), + ]), + ); + render(); + + await screen.findByText('Todo List'); + expect(screen.queryByText('Memories')).not.toBeInTheDocument(); + expect(screen.queryByText('Trip Photos')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AADD-008: toggling an addon persists it, refreshes the store and toasts', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + addonsRoute([buildAddon({ id: 'todo', enabled: false })]), + http.put('/api/admin/addons/todo', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Todo List'); + + const toggle = screen.getByRole('switch', { name: 'Todo List' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await user.click(toggle); + + await waitFor(() => expect(body).toEqual({ enabled: true })); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(loadAddonsSpy).toHaveBeenCalled(); + await screen.findByText('Addon updated'); + }); + + it('FE-MOB-AADD-009: a failing toggle rolls the switch back', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([buildAddon({ id: 'todo', enabled: true })]), + http.put('/api/admin/addons/todo', () => HttpResponse.error()), + ); + render(<>); + await screen.findByText('Todo List'); + + const toggle = screen.getByRole('switch', { name: 'Todo List' }); + await user.click(toggle); + + await screen.findByText('Failed to update addon'); + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'true')); + expect(loadAddonsSpy).not.toHaveBeenCalled(); + }); + + it('FE-MOB-AADD-010: bag tracking shows under an enabled packing addon and reports clicks', async () => { + const user = userEvent.setup(); + const onToggleBagTracking = vi.fn(); + server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })])); + render(); + + await screen.findByText('Bag Tracking'); + expect(screen.getByText('Enable weight and bag assignment for packing items')).toBeInTheDocument(); + const bagToggle = screen.getByRole('switch', { name: 'Bag Tracking' }); + expect(bagToggle).toHaveAttribute('aria-checked', 'false'); + + await user.click(bagToggle); + expect(onToggleBagTracking).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-AADD-011: bag tracking stays hidden when packing is off or the handler is missing', async () => { + server.use(addonsRoute([buildAddon({ id: 'packing', enabled: false })])); + const { unmount } = render(); + await screen.findByText('Lists'); + expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); + unmount(); + + server.use(addonsRoute([buildAddon({ id: 'packing', enabled: true })])); + render(); + await screen.findByText('Lists'); + expect(screen.queryByText('Bag Tracking')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AADD-012: the collab sub-features render and report the toggled key', async () => { + const user = userEvent.setup(); + const onToggleCollabFeature = vi.fn(); + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + await screen.findByText('Chat'); + expect(screen.getByText('Notes')).toBeInTheDocument(); + expect(screen.getByText('Polls')).toBeInTheDocument(); + expect(screen.getByText("What's Next")).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Chat' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Notes' })).toHaveAttribute('aria-checked', 'false'); + + await user.click(screen.getByRole('switch', { name: 'Polls' })); + expect(onToggleCollabFeature).toHaveBeenCalledWith('polls'); + }); + + it('FE-MOB-AADD-013: collab sub-features stay hidden without the handler props', async () => { + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render(); + + await screen.findByText('Collab'); + expect(screen.queryByText('Polls')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AADD-014: photo providers render as sub-rows under the journey addon', async () => { + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), + buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), + ]), + ); + render(); + + await screen.findByText('Immich'); + expect(screen.getByText('Global — Available as a standalone section in the main navigation')).toBeInTheDocument(); + expect(screen.getByText('Synology Photos')).toBeInTheDocument(); + expect(screen.getByText('Unsplash')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true'); + // Journey + three providers, and providers are not shown as top-level rows. + expect(screen.getAllByRole('switch')).toHaveLength(4); + expect(screen.queryByText('Global')).toBeInTheDocument(); + }); + + it('FE-MOB-AADD-015: toggling a provider persists it and rolls back on failure', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: true }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: false }), + buildAddon({ id: 'unsplash', name: 'Unsplash', description: 'Stock photos', type: 'photo_provider', enabled: false }), + ]), + http.put('/api/admin/addons/immich', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + http.put('/api/admin/addons/unsplash', () => HttpResponse.error()), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(screen.getByRole('switch', { name: 'Immich' })); + await waitFor(() => expect(body).toEqual({ enabled: true })); + expect(screen.getByRole('switch', { name: 'Immich' })).toHaveAttribute('aria-checked', 'true'); + await screen.findByText('Addon updated'); + + await user.click(screen.getByRole('switch', { name: 'Unsplash' })); + await screen.findByText('Failed to update addon'); + await waitFor(() => expect(screen.getByRole('switch', { name: 'Unsplash' })).toHaveAttribute('aria-checked', 'false')); + }); + + it('FE-MOB-AADD-016: a disabled AI-parsing addon renders the integration row without its config', async () => { + server.use(addonsRoute([llmAddon({ provider: 'local' })].map(a => ({ ...a, enabled: false })))); + render(); + + await screen.findByText('AI Parsing'); + expect(screen.getByText('Integration — Backend services and API integrations with no dedicated page')).toBeInTheDocument(); + expect(screen.queryByText('Connection')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AADD-017: the local provider lists installed models and selecting a chip fills the model field', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls)); + render(); + + await screen.findByText('Installed on the server'); + await screen.findByRole('button', { name: 'llama3:8b' }); + expect(urls[0]).toBe('http://localhost:11434/v1'); + + await user.click(screen.getByRole('button', { name: 'llama3:8b' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); + + // qwen3:8b is installed, so the recommended row offers "Use" instead of "Pull". + await user.click(screen.getByRole('button', { name: 'Use' })); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); + }); + + it('FE-MOB-AADD-018: an unreachable Ollama shows the error, Refresh retries', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + calls += 1; + return calls === 1 + ? HttpResponse.json({ error: 'down' }, { status: 500 }) + : HttpResponse.json({ models: [] }); + }), + ); + render(); + + await screen.findByText(/Request failed with status code 500/); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + await screen.findByText('No models installed yet — pull one below.'); + expect(calls).toBe(2); + }); + + it('FE-MOB-AADD-019: the base URL is used for model lookups on blur', async () => { + const user = userEvent.setup(); + const urls: (string | null)[] = []; + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute([], urls)); + render(); + + await screen.findByText('Installed on the server'); + const baseUrl = screen.getByPlaceholderText('http://localhost:11434/v1'); + await user.type(baseUrl, 'http://ollama.lan:11434/v1'); + await user.tab(); + + await waitFor(() => expect(urls).toContain('http://ollama.lan:11434/v1')); + }); + + it('FE-MOB-AADD-020: switching providers swaps the base URL field and the model hint', async () => { + const user = userEvent.setup(); + server.use(addonsRoute([llmAddon({ provider: 'local', apiKey: '••••••••' })]), modelsRoute([])); + render(); + + await screen.findByText('Installed on the server'); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('select or pull below'), 'mistral:7b'); + expect(screen.getByDisplayValue('mistral:7b')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'OpenAI' })); + expect(screen.getByPlaceholderText('https://api.openai.com/v1')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('gpt-4o')).toBeInTheDocument(); + expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Anthropic' })); + expect(screen.queryByPlaceholderText('https://api.openai.com/v1')).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('claude-opus-4-8')).toBeInTheDocument(); + expect(screen.getByText(/Anthropic reads PDFs/)).toBeInTheDocument(); + }); + + it('FE-MOB-AADD-021: pulling a model streams progress, then selects it', async () => { + const user = userEvent.setup(); + let pulled: unknown = null; + let modelCalls = 0; + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + http.get('/api/admin/llm/local/models', () => { + modelCalls += 1; + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] }); + }), + http.post('/api/admin/llm/local/pull', async ({ request }) => { + pulled = await request.json(); + await delay(150); + return new HttpResponse( + '{"status":"pulling manifest"}\n{"status":"downloading","total":100,"completed":40}\nnot-json\n', + { headers: { 'Content-Type': 'application/x-ndjson' } }, + ); + }), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + // While the request is in flight the row shows the busy label and status line. + await screen.findByText('Pulling…'); + expect(screen.getByText('starting…')).toBeInTheDocument(); + + await screen.findByText('Model pulled'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' }); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + // Reloaded models now contain the pulled one, so the row switches to "Selected". + await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); + }); + + it('FE-MOB-AADD-022: a failing pull surfaces the server error', async () => { + const user = userEvent.setup(); + server.use( + addonsRoute([llmAddon({ provider: 'local' })]), + modelsRoute([]), + http.post('/api/admin/llm/local/pull', () => HttpResponse.json({ error: 'no disk space' }, { status: 500 })), + ); + render(<>); + + await screen.findByText('No models installed yet — pull one below.'); + await user.click(screen.getByRole('button', { name: 'Pull' })); + + await screen.findByText('no disk space'); + expect(screen.getByRole('button', { name: 'Pull' })).toBeEnabled(); + }); + + it('FE-MOB-AADD-023: saving posts the whole config and reports both outcomes', async () => { + const user = userEvent.setup(); + const bodies: unknown[] = []; + server.use( + addonsRoute([llmAddon({ provider: 'openai', model: 'gpt-4o', baseUrl: 'https://api.openai.com/v1', apiKey: '••••••••', multimodal: true })]), + http.put('/api/admin/addons/llm_parsing', async ({ request }) => { + bodies.push(await request.json()); + return bodies.length === 1 ? HttpResponse.json({ success: true }) : HttpResponse.error(); + }), + ); + render(<>); + + const save = await screen.findByRole('button', { name: 'Save' }); + await user.click(save); + + await screen.findByText('Saved'); + expect(bodies[0]).toEqual({ + config: { + provider: 'openai', + model: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + apiKey: '••••••••', + multimodal: true, + }, + }); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await screen.findByText('Failed to save'); + }); + + it('FE-MOB-AADD-024: the API key field can be revealed', async () => { + const user = userEvent.setup(); + server.use(addonsRoute([llmAddon({ provider: 'anthropic', apiKey: 'sk-secret' })])); + render(); + + const field = await screen.findByDisplayValue('sk-secret'); + expect(field).toHaveAttribute('type', 'password'); + + await user.click(within(field.parentElement as HTMLElement).getByRole('button', { name: 'Show or hide' })); + expect(screen.getByDisplayValue('sk-secret')).toHaveAttribute('type', 'text'); + + await user.type(screen.getByDisplayValue('sk-secret'), '-rotated'); + expect(screen.getByDisplayValue('sk-secret-rotated')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminAuditLogPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminAuditLogPanel.test.tsx new file mode 100644 index 000000000..bab99028f --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminAuditLogPanel.test.tsx @@ -0,0 +1,217 @@ +// FE-MOB-AUDIT-001 to FE-MOB-AUDIT-012 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import MAdminAuditLogPanel from '../../../../src/mobile/screens/admin/MAdminAuditLogPanel'; + +const ENTRY_1 = { + id: 1, + created_at: '2025-06-01T10:30:00Z', + user_id: 5, + username: 'alice', + user_email: 'alice@example.com', + action: 'trip.create', + resource: '/trips/42', + details: { title: 'Test' }, + ip: '127.0.0.1', +}; + +const ENTRY_2 = { + id: 2, + created_at: '2025-06-02T11:00:00Z', + user_id: 6, + username: 'bob', + user_email: 'bob@example.com', + action: 'trip.delete', + resource: null, + details: null, + ip: null, +}; + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminAuditLogPanel', () => { + it('FE-MOB-AUDIT-001: header, subtitle and the loading placeholder render on mount', () => { + server.use( + http.get('/api/admin/audit-log', async () => { + await new Promise(() => {}); + return HttpResponse.json({ entries: [], total: 0 }); + }), + ); + render(); + + expect(screen.getByText('Audit')).toBeInTheDocument(); + expect( + screen.getByText('Security-sensitive and administration events (backups, users, MFA, settings).'), + ).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled(); + }); + + it('FE-MOB-AUDIT-002: empty state and a zeroed counter when there is nothing to show', async () => { + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 }))); + render(); + + await screen.findByText('No audit entries yet.'); + expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument(); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-003: an entry card shows action, user, resource, ip and details', async () => { + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 }))); + render(); + + await screen.findByText('trip.create'); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('Resource')).toBeInTheDocument(); + expect(screen.getByText('IP')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('/trips/42')).toBeInTheDocument(); + expect(screen.getByText('127.0.0.1')).toBeInTheDocument(); + expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument(); + expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-004: the timestamp is rendered in the server timezone', async () => { + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 }))); + render(); + + await screen.findByText('trip.create'); + expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-005: timestamps without a Z suffix are treated as UTC', async () => { + const naive = { ...ENTRY_1, created_at: '2025-06-01T10:30:00' }; + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [naive], total: 1 }))); + render(); + + await screen.findByText('trip.create'); + expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-006: an unusable timezone falls back to the raw timestamp', async () => { + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 }))); + render(); + + await screen.findByText('trip.create'); + expect(screen.getByText('2025-06-01T10:30:00Z')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-007: the user label falls back through email, id and dash', async () => { + const entries = [ + { ...ENTRY_1, id: 10, action: 'a.username' }, + { ...ENTRY_1, id: 11, username: null, action: 'a.email' }, + { ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' }, + { ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' }, + ]; + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 }))); + render(); + + await screen.findByText('a.username'); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('#7')).toBeInTheDocument(); + expect(screen.getAllByText('—')).toHaveLength(1); + }); + + it('FE-MOB-AUDIT-008: null resource, ip and details render as a dash', async () => { + const emptyDetails = { ...ENTRY_1, id: 21, action: 'a.emptyobj', details: {} }; + server.use( + http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_2, emptyDetails], total: 2 })), + ); + render(); + + await screen.findByText('trip.delete'); + // ENTRY_2 contributes three dashes, the empty details object a fourth + expect(screen.getAllByText('—')).toHaveLength(4); + }); + + it('FE-MOB-AUDIT-009: Load more appends the next page and disappears when complete', async () => { + const user = userEvent.setup(); + const offsets: string[] = []; + server.use( + http.get('/api/admin/audit-log', ({ request }) => { + const offset = new URL(request.url).searchParams.get('offset') ?? ''; + offsets.push(offset); + return offset === '0' + ? HttpResponse.json({ entries: [ENTRY_1], total: 2 }) + : HttpResponse.json({ entries: [ENTRY_2], total: 2 }); + }), + ); + render(); + + await screen.findByText('trip.create'); + await user.click(screen.getByRole('button', { name: 'Load more' })); + + await screen.findByText('trip.delete'); + expect(screen.getByText('trip.create')).toBeInTheDocument(); + expect(screen.getByText('2 loaded · 2 total')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument(); + expect(offsets).toEqual(['0', '100']); + }); + + it('FE-MOB-AUDIT-010: a failing Load more keeps the entries already loaded', async () => { + const user = userEvent.setup(); + let call = 0; + server.use( + http.get('/api/admin/audit-log', () => { + call += 1; + return call === 1 + ? HttpResponse.json({ entries: [ENTRY_1], total: 5 }) + : HttpResponse.json({ error: 'boom' }, { status: 500 }); + }), + ); + render(); + + await screen.findByText('trip.create'); + await user.click(screen.getByRole('button', { name: 'Load more' })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Load more' })).not.toBeDisabled()); + expect(screen.getByText('trip.create')).toBeInTheDocument(); + expect(screen.getByText('1 loaded · 5 total')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-011: Refresh throws away the appended pages', async () => { + const user = userEvent.setup(); + let call = 0; + server.use( + http.get('/api/admin/audit-log', () => { + call += 1; + if (call === 1) return HttpResponse.json({ entries: [ENTRY_1], total: 2 }); + if (call === 2) return HttpResponse.json({ entries: [ENTRY_2], total: 2 }); + return HttpResponse.json({ entries: [{ ...ENTRY_1, id: 99, action: 'after.refresh' }], total: 1 }); + }), + ); + render(); + + await screen.findByText('trip.create'); + await user.click(screen.getByRole('button', { name: 'Load more' })); + await screen.findByText('trip.delete'); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + await screen.findByText('after.refresh'); + expect(screen.queryByText('trip.create')).not.toBeInTheDocument(); + expect(screen.queryByText('trip.delete')).not.toBeInTheDocument(); + expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument(); + }); + + it('FE-MOB-AUDIT-012: a failing first page falls back to the empty state', async () => { + server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + render(); + + await screen.findByText('No audit entries yet.'); + expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminBackupPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminBackupPanel.test.tsx new file mode 100644 index 000000000..e21ecb323 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminBackupPanel.test.tsx @@ -0,0 +1,455 @@ +// FE-MOB-MBKP-001 to FE-MOB-MBKP-026 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminBackupPanel from '../../../../src/mobile/screens/admin/MAdminBackupPanel'; + +const manualBackup = { filename: 'backup-2025-01-15.zip', created_at: '2025-01-15T10:00:00Z', size: 2_048_000 }; +const autoBackup = { filename: 'auto-backup-2025-02-01.zip', created_at: '2025-02-01T02:00:00Z', size: 512_000 }; + +const DEFAULT_AUTO = { enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 }; + +function autoSettingsHandler(settings: Record = DEFAULT_AUTO, timezone: string | null = 'UTC') { + return http.get('/api/backup/auto-settings', () => + HttpResponse.json(timezone === null ? { settings } : { settings, timezone }), + ); +} + +function listHandler(backups: unknown[]) { + return http.get('/api/backup/list', () => HttpResponse.json({ backups })); +} + +function withToast() { + return render(<>); +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +/** Stub window.location so the post-restore reload cannot navigate jsdom. */ +function stubReload() { + const reload = vi.fn(); + vi.stubGlobal('location', { ...window.location, reload }); + return reload; +} + +describe('MAdminBackupPanel', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) }); + server.use(listHandler([manualBackup]), autoSettingsHandler()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('FE-MOB-MBKP-001: renders the backup card with filename and formatted size', async () => { + render(); + + expect(await screen.findByText('backup-2025-01-15.zip')).toBeInTheDocument(); + expect(screen.getByText('Data Backup')).toBeInTheDocument(); + expect(screen.getByText('Database and all uploaded files')).toBeInTheDocument(); + expect(screen.getByText('2.0 MB')).toBeInTheDocument(); + expect(screen.queryByText('Auto')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-002: sub-megabyte sizes are shown in KB and missing metadata as a dash', async () => { + server.use(listHandler([ + { filename: 'small.zip', created_at: '2025-03-01T08:00:00Z', size: 4096 }, + { filename: 'unknown.zip', created_at: null, size: 0 }, + ])); + render(); + + expect(await screen.findByText('4.0 KB')).toBeInTheDocument(); + expect(screen.getAllByText('-')).toHaveLength(2); + }); + + it('FE-MOB-MBKP-003: files named auto-backup-* get the Auto badge', async () => { + server.use(listHandler([autoBackup])); + render(); + + expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument(); + expect(screen.getByText('Auto')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-004: shows the loading row first and the empty state when nothing is stored', async () => { + server.use(listHandler([])); + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + + expect(await screen.findByText('No backups yet')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create first backup' })).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-005: a failing list request shows the load error toast', async () => { + server.use(http.get('/api/backup/list', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + withToast(); + + expect(await screen.findByText('Failed to load backups')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-006: the refresh button re-reads the list', async () => { + const user = userEvent.setup(); + let calls = 0; + server.use(http.get('/api/backup/list', () => { + calls += 1; + return HttpResponse.json({ backups: calls === 1 ? [manualBackup] : [manualBackup, autoBackup] }); + })); + render(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Refresh' })); + + expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument(); + expect(calls).toBe(2); + }); + + it('FE-MOB-MBKP-007: creating a backup posts, toasts and reloads the list', async () => { + const user = userEvent.setup(); + let created = false; + server.use( + http.post('/api/backup/create', () => { created = true; return HttpResponse.json({ success: true }); }), + http.get('/api/backup/list', () => HttpResponse.json({ backups: created ? [manualBackup, autoBackup] : [manualBackup] })), + ); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Create Backup' })); + + expect(await screen.findByText('Backup created successfully')).toBeInTheDocument(); + expect(await screen.findByText('auto-backup-2025-02-01.zip')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-008: a failing create shows the create error toast', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/backup/create', () => HttpResponse.json({ error: 'disk full' }, { status: 500 }))); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Create Backup' })); + + expect(await screen.findByText('Failed to create backup')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-009: the empty-state shortcut also creates a backup', async () => { + const user = userEvent.setup(); + let created = false; + server.use( + listHandler([]), + http.post('/api/backup/create', () => { created = true; return HttpResponse.json({ success: true }); }), + ); + withToast(); + await screen.findByText('No backups yet'); + + await user.click(screen.getByRole('button', { name: 'Create first backup' })); + + await waitFor(() => expect(created).toBe(true)); + }); + + it('FE-MOB-MBKP-010: a failing download shows the download error toast', async () => { + const user = userEvent.setup(); + server.use(http.get('/api/backup/download/:filename', () => new HttpResponse(null, { status: 500 }))); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Download' })); + + expect(await screen.findByText('Download failed')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-011: Restore opens the warning sheet and cancelling leaves the API untouched', async () => { + const user = userEvent.setup(); + let restored = false; + server.use(http.post('/api/backup/restore/:filename', () => { restored = true; return HttpResponse.json({ ok: true }); })); + render(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Restore' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Restore Backup?')).toBeInTheDocument(); + expect(within(dialog).getByText('backup-2025-01-15.zip')).toBeInTheDocument(); + expect(within(dialog).getByText(/Tip: Create a backup of the current state/)).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(restored).toBe(false); + }); + + it('FE-MOB-MBKP-012: confirming a restore calls the API and reloads the page after 1.5s', async () => { + const user = userEvent.setup(); + let restoredFile = ''; + server.use(http.post('/api/backup/restore/:filename', ({ params }) => { + restoredFile = String(params.filename); + return HttpResponse.json({ success: true }); + })); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + const reload = stubReload(); + await user.click(screen.getByRole('button', { name: 'Restore' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' })); + + expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument(); + expect(restoredFile).toBe('backup-2025-01-15.zip'); + await waitFor(() => expect(reload).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('FE-MOB-MBKP-013: a failing restore surfaces the server error and clears the busy state', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/backup/restore/:filename', () => + HttpResponse.json({ error: 'Archive is corrupt' }, { status: 400 }), + )); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Restore' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' })); + + expect(await screen.findByText('Archive is corrupt')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Restore' })).toBeEnabled()); + }); + + it('FE-MOB-MBKP-014: picking a file opens the upload confirmation with the file name', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('backup-2025-01-15.zip'); + + // The visible button only forwards to the hidden picker; jsdom cannot open it + const picker = vi.spyOn(fileInput(), 'click'); + await user.click(screen.getByRole('button', { name: 'Upload Backup' })); + expect(picker).toHaveBeenCalled(); + + fireEvent.change(fileInput(), { + target: { files: [new File(['zip'], 'restore-me.zip', { type: 'application/zip' })] }, + }); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('restore-me.zip')).toBeInTheDocument(); + // The picker is cleared so re-selecting the same file fires change again + expect(fileInput().value).toBe(''); + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('FE-MOB-MBKP-015: an empty file selection opens no sheet', async () => { + render(); + await screen.findByText('backup-2025-01-15.zip'); + + fireEvent.change(fileInput(), { target: { files: [] } }); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-016: confirming an upload restore posts the archive and reloads', async () => { + const user = userEvent.setup(); + let uploaded = false; + server.use(http.post('/api/backup/upload-restore', () => { uploaded = true; return HttpResponse.json({ success: true }); })); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + const reload = stubReload(); + fireEvent.change(fileInput(), { + target: { files: [new File(['zip'], 'restore-me.zip', { type: 'application/zip' })] }, + }); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' })); + + expect(await screen.findByText('Backup restored. Page will reload…')).toBeInTheDocument(); + expect(uploaded).toBe(true); + await waitFor(() => expect(reload).toHaveBeenCalled(), { timeout: 4000 }); + }); + + it('FE-MOB-MBKP-017: a failing upload restore toasts the error and re-enables the upload button', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/backup/upload-restore', () => + HttpResponse.json({ error: 'Not a TREK archive' }, { status: 400 }), + )); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + fireEvent.change(fileInput(), { + target: { files: [new File(['zip'], 'broken.zip', { type: 'application/zip' })] }, + }); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Yes, restore' })); + + expect(await screen.findByText('Not a TREK archive')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Upload Backup' })).toBeEnabled()); + }); + + it('FE-MOB-MBKP-018: deleting asks for confirmation and drops the row on success', async () => { + const user = userEvent.setup(); + let deletedFile = ''; + server.use(http.delete('/api/backup/:filename', ({ params }) => { + deletedFile = String(params.filename); + return HttpResponse.json({ success: true }); + })); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Delete backup "backup-2025-01-15.zip"?')).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(deletedFile).toBe(''); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Backup deleted')).toBeInTheDocument(); + expect(deletedFile).toBe('backup-2025-01-15.zip'); + await waitFor(() => expect(screen.getByText('No backups yet')).toBeInTheDocument()); + }); + + it('FE-MOB-MBKP-019: a failing delete keeps the row and toasts the error', async () => { + const user = userEvent.setup(); + server.use(http.delete('/api/backup/:filename', () => HttpResponse.json({ error: 'locked' }, { status: 409 }))); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Failed to delete')).toBeInTheDocument(); + expect(screen.getByText('backup-2025-01-15.zip')).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-020: the auto-backup schedule is hidden until the toggle is on', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('Auto-Backup'); + + const toggle = screen.getByRole('switch', { name: 'Enable auto-backup' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText('Hourly')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + + await user.click(toggle); + + expect(screen.getByText('Hourly')).toBeInTheDocument(); + expect(screen.getByText('Daily')).toBeInTheDocument(); + expect(screen.getByText('Weekly')).toBeInTheDocument(); + expect(screen.getByText('Monthly')).toBeInTheDocument(); + expect(screen.getByText('7 days')).toBeInTheDocument(); + expect(screen.getByText('Keep forever')).toBeInTheDocument(); + // Changing anything marks the form dirty + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); + }); + + it('FE-MOB-MBKP-021: the hour picker renders 24h labels and names the server timezone', async () => { + server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }, 'Europe/Berlin')); + render(); + + expect(await screen.findByText('Run at hour')).toBeInTheDocument(); + expect(screen.getByText('Server local time (24h format) (Timezone: Europe/Berlin)')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveValue('2'); + expect(screen.getByRole('option', { name: '00:00' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '23:00' })).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-022: with a 12h user preference the hours are labelled AM/PM', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ time_format: '12h' }) }); + server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }, null)); + render(); + + expect(await screen.findByText('Server local time (12h format)')).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '12:00 AM' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '11:00 AM' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: '1:00 PM' })).toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-023: hourly hides the hour picker, weekly shows weekdays, monthly a day select', async () => { + const user = userEvent.setup(); + server.use(autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true })); + render(); + await screen.findByText('Run at hour'); + + await user.click(screen.getByText('Hourly')); + expect(screen.queryByText('Run at hour')).not.toBeInTheDocument(); + + await user.click(screen.getByText('Weekly')); + expect(screen.getByText('Day of week')).toBeInTheDocument(); + expect(screen.getByText('Sun')).toBeInTheDocument(); + expect(screen.getByText('Sat')).toBeInTheDocument(); + + await user.click(screen.getByText('Monthly')); + expect(screen.queryByText('Day of week')).not.toBeInTheDocument(); + expect(screen.getByText('Day of month')).toBeInTheDocument(); + // Hour picker plus the 1–28 day picker + const selects = screen.getAllByRole('combobox'); + expect(selects).toHaveLength(2); + expect(within(selects[1]).getAllByRole('option')).toHaveLength(28); + + fireEvent.change(selects[1], { target: { value: '15' } }); + expect(selects[1]).toHaveValue('15'); + }); + + it('FE-MOB-MBKP-024: saving sends the edited schedule and confirms with a toast', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }), + http.put('/api/backup/auto-settings', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ settings: body }); + }), + ); + withToast(); + await screen.findByText('Run at hour'); + + await user.click(screen.getByText('Weekly')); + await user.click(screen.getByText('Fri')); + await user.click(screen.getByText('30 days')); + fireEvent.change(screen.getByRole('combobox'), { target: { value: '5' } }); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Auto-backup settings saved')).toBeInTheDocument(); + expect(body).toEqual({ enabled: true, interval: 'weekly', keep_days: 30, hour: 5, day_of_week: 5, day_of_month: 1 }); + // Server echo resets the dirty flag + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()); + }); + + it('FE-MOB-MBKP-025: a failing save toasts the settings error and keeps the form dirty', async () => { + const user = userEvent.setup(); + server.use( + autoSettingsHandler({ ...DEFAULT_AUTO, enabled: true }), + http.put('/api/backup/auto-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + withToast(); + await screen.findByText('Run at hour'); + + await user.click(screen.getByText('Weekly')); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Failed to save settings')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()); + }); + + it('FE-MOB-MBKP-026: an unreachable auto-settings endpoint leaves the built-in defaults in place', async () => { + server.use(http.get('/api/backup/auto-settings', () => HttpResponse.json({}, { status: 500 }))); + withToast(); + await screen.findByText('backup-2025-01-15.zip'); + + expect(screen.getByRole('switch', { name: 'Enable auto-backup' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.queryByText('Failed to load backups')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MBKP-027: an unusable server timezone falls back to the raw timestamp', async () => { + server.use(autoSettingsHandler(DEFAULT_AUTO, 'Not/AZone')); + render(); + + expect(await screen.findByText('2025-01-15T10:00:00Z')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminCategoryManager.test.tsx b/client/tests/unit/mobile/admin/MAdminCategoryManager.test.tsx new file mode 100644 index 000000000..adcd43e89 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminCategoryManager.test.tsx @@ -0,0 +1,298 @@ +// FE-MOB-MCAT-001 to FE-MOB-MCAT-016 +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildAdmin, buildCategory } from '../../../helpers/factories'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminCategoryManager from '../../../../src/mobile/screens/admin/MAdminCategoryManager'; + +function withToast() { + return render(<>); +} + +// The preset swatches carry no label — jsdom normalises the inline hex to rgb(). +function swatch(hex: string): HTMLElement { + const rgb = `rgb(${parseInt(hex.slice(1, 3), 16)}, ${parseInt(hex.slice(3, 5), 16)}, ${parseInt(hex.slice(5, 7), 16)})`; + const found = Array.from(document.querySelectorAll('button')).find(b => b.style.backgroundColor === rgb); + if (!found) throw new Error(`no colour swatch for ${hex}`); + return found; +} + +describe('MAdminCategoryManager', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + server.use(http.get('/api/categories', () => HttpResponse.json({ categories: [] }))); + }); + + it('FE-MOB-MCAT-001: shows a spinner while the list is loading, then the empty state', async () => { + render(); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + + expect(await screen.findByText('No categories yet')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-002: renders the card head and the New Category action', async () => { + render(); + await screen.findByText('No categories yet'); + + expect(screen.getByText('Categories')).toBeInTheDocument(); + expect(screen.getByText('Manage categories for places')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'New Category' })).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-003: a failing list request surfaces the load error toast', async () => { + server.use(http.get('/api/categories', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + withToast(); + + expect(await screen.findByText('Failed to load categories')).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-004: lists the categories from the API with name and colour badge', async () => { + server.use(http.get('/api/categories', () => HttpResponse.json({ + categories: [ + buildCategory({ id: 1, name: 'Museum', color: '#10b981', icon: 'Landmark' }), + buildCategory({ id: 2, name: 'Hotel', color: '#3b82f6', icon: 'BedDouble' }), + ], + }))); + render(); + + expect(await screen.findByText('Museum')).toBeInTheDocument(); + expect(screen.getByText('Hotel')).toBeInTheDocument(); + expect(screen.getByText('#10b981')).toBeInTheDocument(); + expect(screen.getByText('#3b82f6')).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-005: New Category opens the form with the default preview label', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('No categories yet'); + + await user.click(screen.getByRole('button', { name: 'New Category' })); + + expect(screen.getByPlaceholderText('Category name')).toHaveValue(''); + expect(screen.getByText('Icon')).toBeInTheDocument(); + expect(screen.getByText('Color')).toBeInTheDocument(); + // Empty name falls back to the generic preview label + expect(screen.getByText('Category')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); + }); + + it('FE-MOB-MCAT-006: typing a name enables Create and updates the live preview', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('No categories yet'); + await user.click(screen.getByRole('button', { name: 'New Category' })); + + await user.type(screen.getByPlaceholderText('Category name'), 'Parks'); + + expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled(); + expect(screen.getByText('Parks')).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-007: creating posts name, chosen icon and preset colour, and appends the row', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use(http.post('/api/categories', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ category: buildCategory({ id: 77, name: 'Parks', color: '#10b981', icon: 'TreePine' }) }); + })); + withToast(); + await screen.findByText('No categories yet'); + await user.click(screen.getByRole('button', { name: 'New Category' })); + + await user.type(screen.getByPlaceholderText('Category name'), 'Parks'); + await user.click(screen.getByTitle('Nature')); + await user.click(swatch('#10b981')); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => expect(screen.getByText('Category created')).toBeInTheDocument()); + expect(body).toEqual({ name: 'Parks', color: '#10b981', icon: 'TreePine' }); + // Form closed, new row present with its colour badge + expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument(); + expect(screen.getByText('#10b981')).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-008: a rejected create shows the server error message', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/categories', () => HttpResponse.json({ error: 'Name already taken' }, { status: 409 }))); + withToast(); + await screen.findByText('No categories yet'); + await user.click(screen.getByRole('button', { name: 'New Category' })); + await user.type(screen.getByPlaceholderText('Category name'), 'Parks'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + expect(await screen.findByText('Name already taken')).toBeInTheDocument(); + // Form stays open so the admin can correct the name + expect(screen.getByPlaceholderText('Category name')).toHaveValue('Parks'); + }); + + it('FE-MOB-MCAT-009: Cancel closes the create form', async () => { + const user = userEvent.setup(); + render(); + await screen.findByText('No categories yet'); + await user.click(screen.getByRole('button', { name: 'New Category' })); + expect(screen.getByPlaceholderText('Category name')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByPlaceholderText('Category name')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-010: the custom colour input marks the colour as non-preset and is sent on save', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use(http.post('/api/categories', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ category: buildCategory({ id: 3, name: 'Custom', color: '#123456' }) }); + })); + withToast(); + await screen.findByText('No categories yet'); + await user.click(screen.getByRole('button', { name: 'New Category' })); + + // The eyedropper button only forwards to the hidden colour input; drive it directly. + const pickerButton = screen.getByTitle('Choose custom color'); + await user.click(pickerButton); + const colorInput = document.querySelector('input[type="color"]') as HTMLInputElement; + fireEvent.change(colorInput, { target: { value: '#123456' } }); + + expect(pickerButton.style.backgroundColor).toBe('rgb(18, 52, 86)'); + + await user.type(screen.getByPlaceholderText('Category name'), 'Custom'); + await user.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => expect(body).toEqual({ name: 'Custom', color: '#123456', icon: 'MapPin' })); + }); + + it('FE-MOB-MCAT-011: the edit button opens an inline form prefilled from the category', async () => { + const user = userEvent.setup(); + server.use(http.get('/api/categories', () => HttpResponse.json({ + categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })], + }))); + render(); + await screen.findByText('Hotels'); + + await user.click(screen.getByRole('button', { name: 'Edit' })); + + expect(screen.getByDisplayValue('Hotels')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument(); + expect(document.querySelector('input[type="color"]')).toHaveValue('#ef4444'); + }); + + it('FE-MOB-MCAT-012: saving an edit PUTs the change and replaces the row in place', async () => { + const user = userEvent.setup(); + let putUrl = ''; + server.use( + http.get('/api/categories', () => HttpResponse.json({ + categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })], + })), + http.put('/api/categories/:id', async ({ request, params }) => { + putUrl = String(params.id); + const b = await request.json() as Record; + return HttpResponse.json({ category: buildCategory({ id: 5, name: String(b.name), color: '#ef4444' }) }); + }), + ); + withToast(); + await screen.findByText('Hotels'); + await user.click(screen.getByRole('button', { name: 'Edit' })); + + const input = screen.getByDisplayValue('Hotels'); + await user.clear(input); + await user.type(input, 'Lodging'); + await user.click(screen.getByRole('button', { name: 'Update' })); + + await waitFor(() => expect(screen.getByText('Category updated')).toBeInTheDocument()); + expect(putUrl).toBe('5'); + expect(screen.getByText('Lodging')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('Lodging')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-013: opening the edit form closes an open create form', async () => { + const user = userEvent.setup(); + server.use(http.get('/api/categories', () => HttpResponse.json({ + categories: [buildCategory({ id: 5, name: 'Hotels' })], + }))); + render(); + await screen.findByText('Hotels'); + + await user.click(screen.getByRole('button', { name: 'New Category' })); + expect(screen.getAllByPlaceholderText('Category name')).toHaveLength(1); + + await user.click(screen.getByRole('button', { name: 'Edit' })); + + // Only the inline edit form remains, prefilled + const inputs = screen.getAllByPlaceholderText('Category name'); + expect(inputs).toHaveLength(1); + expect(inputs[0]).toHaveValue('Hotels'); + }); + + it('FE-MOB-MCAT-014: the trash button asks for confirmation before deleting', async () => { + const user = userEvent.setup(); + let deleted = false; + server.use( + http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })), + http.delete('/api/categories/9', () => { deleted = true; return HttpResponse.json({ success: true }); }), + ); + withToast(); + await screen.findByText('Parks'); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Delete category? Places in this category will not be deleted.')).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + // The sheet stays mounted through its exit animation + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(deleted).toBe(false); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(deleted).toBe(true)); + expect(await screen.findByText('Category deleted')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Parks')).not.toBeInTheDocument()); + }); + + it('FE-MOB-MCAT-015: a failing delete keeps the row and shows the server error', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/categories', () => HttpResponse.json({ categories: [buildCategory({ id: 9, name: 'Parks' })] })), + http.delete('/api/categories/9', () => HttpResponse.json({ error: 'Category in use' }, { status: 400 })), + ); + withToast(); + await screen.findByText('Parks'); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Category in use')).toBeInTheDocument(); + expect(screen.getByText('Parks')).toBeInTheDocument(); + }); + + it('FE-MOB-MCAT-016: picking an icon in the edit form is sent with the update', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.get('/api/categories', () => HttpResponse.json({ + categories: [buildCategory({ id: 5, name: 'Hotels', color: '#ef4444', icon: 'BedDouble' })], + })), + http.put('/api/categories/5', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ category: buildCategory({ id: 5, name: 'Hotels' }) }); + }), + ); + render(); + await screen.findByText('Hotels'); + await user.click(screen.getByRole('button', { name: 'Edit' })); + + await user.click(screen.getByTitle('Camping')); + await user.click(screen.getByRole('button', { name: 'Update' })); + + await waitFor(() => expect(body).toEqual({ name: 'Hotels', color: '#ef4444', icon: 'Tent' })); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx new file mode 100644 index 000000000..9f9b3ad9c --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminDefaultUserSettings.test.tsx @@ -0,0 +1,379 @@ +// FE-MOB-MDUS-001 to FE-MOB-MDUS-024 +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, waitFor, within, fireEvent } from '../../../helpers/render'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildAdmin } from '../../../helpers/factories'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminDefaultUserSettings from '../../../../src/mobile/screens/admin/MAdminDefaultUserSettings'; + +// The live tile preview would pull Leaflet into jsdom; the panel only needs it to render. +vi.mock('../../../../src/components/Map/MapView', () => ({ + MapView: ({ tileUrl }: { tileUrl?: string }) =>
, +})); + +const MAPBOX_STANDARD = 'mapbox://styles/mapbox/standard'; +const OFM_LIBERTY = 'https://tiles.openfreemap.org/styles/liberty'; + +/** Stateful stand-in for the admin defaults endpoint: PUT merges, null deletes. */ +function stubDefaults(initial: Record = {}) { + const state: Record = { ...initial }; + const puts: Record[] = []; + server.use( + http.get('/api/admin/default-user-settings', () => HttpResponse.json(state)), + http.put('/api/admin/default-user-settings', async ({ request }) => { + const body = await request.json() as Record; + puts.push(body); + for (const [key, value] of Object.entries(body)) { + if (value === null) delete state[key]; + else state[key] = value; + } + return HttpResponse.json({ ...state }); + }), + ); + return { puts, state }; +} + +function withToast() { + return render(<>); +} + +/** The reset link lives inside the field label next to its caption. */ +function resetLink(label: string): HTMLElement { + const holder = screen.getAllByText(label).find(el => el.querySelector('button')); + if (!holder) throw new Error(`no reset link next to "${label}"`); + return within(holder).getByRole('button', { name: 'reset' }); +} + +describe('MAdminDefaultUserSettings', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { isAuthenticated: true, user: buildAdmin() }); + stubDefaults(); + }); + + it('FE-MOB-MDUS-001: shows the loading placeholder until the defaults arrive', async () => { + render(); + expect(screen.getByText('Loading…')).toBeInTheDocument(); + + expect(await screen.findByText('Default User Settings')).toBeInTheDocument(); + expect(screen.queryByText('Loading…')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-002: renders every default field with no preselected segment when nothing is set', async () => { + render(); + await screen.findByText('Default User Settings'); + + expect(screen.getByText(/Set instance-wide defaults/)).toBeInTheDocument(); + for (const name of ['Light', 'Dark', 'Auto', '°C Celsius', 'km Metric', '24h (14:30)']) { + expect(screen.getByRole('tab', { name })).toHaveAttribute('aria-selected', 'false'); + } + // No default chosen yet → no reset links anywhere + expect(screen.queryByRole('button', { name: 'reset' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Display currency' })).toBeInTheDocument(); + expect(screen.getByTestId('map-preview')).toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-003: a failing load still renders the (empty) panel', async () => { + server.use(http.get('/api/admin/default-user-settings', () => HttpResponse.json({}, { status: 500 }))); + render(); + + expect(await screen.findByText('Default User Settings')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Standard (free)' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('FE-MOB-MDUS-004: picking a colour mode saves it and shows the confirmation toast', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + withToast(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('tab', { name: 'Dark' })); + + expect(await screen.findByText('Default saved')).toBeInTheDocument(); + expect(puts).toEqual([{ dark_mode: 'dark' }]); + await waitFor(() => expect(screen.getByRole('tab', { name: 'Dark' })).toHaveAttribute('aria-selected', 'true')); + }); + + it('FE-MOB-MDUS-005: a legacy boolean dark_mode still highlights the matching segment', async () => { + stubDefaults({ dark_mode: true }); + render(); + await screen.findByText('Default User Settings'); + + expect(screen.getByRole('tab', { name: 'Dark' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('FE-MOB-MDUS-006: dark_mode false maps to Light, "auto" maps to Auto', async () => { + stubDefaults({ dark_mode: false }); + const { unmount } = render(); + await screen.findByText('Default User Settings'); + expect(screen.getByRole('tab', { name: 'Light' })).toHaveAttribute('aria-selected', 'true'); + unmount(); + + stubDefaults({ dark_mode: 'auto' }); + render(); + await screen.findByText('Default User Settings'); + expect(screen.getByRole('tab', { name: 'Auto' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('FE-MOB-MDUS-007: unit and time-format segments each save their own key', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('tab', { name: '°F Fahrenheit' })); + await waitFor(() => expect(screen.getByRole('tab', { name: '°F Fahrenheit' })).toHaveAttribute('aria-selected', 'true')); + await user.click(screen.getByRole('tab', { name: 'mi Imperial' })); + await waitFor(() => expect(screen.getByRole('tab', { name: 'mi Imperial' })).toHaveAttribute('aria-selected', 'true')); + await user.click(screen.getByRole('tab', { name: '12h (2:30 PM)' })); + + await waitFor(() => expect(puts).toEqual([ + { temperature_unit: 'fahrenheit' }, + { distance_unit: 'imperial' }, + { time_format: '12h' }, + ])); + }); + + it('FE-MOB-MDUS-008: a set default gets a reset link that clears it server-side', async () => { + const user = userEvent.setup(); + const { puts, state } = stubDefaults({ temperature_unit: 'celsius' }); + withToast(); + await screen.findByText('Default User Settings'); + + expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true'); + await user.click(resetLink('Temperature Unit')); + + expect(await screen.findByText('Reset to built-in default')).toBeInTheDocument(); + expect(puts).toEqual([{ temperature_unit: null }]); + expect(state.temperature_unit).toBeUndefined(); + await waitFor(() => expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'false')); + }); + + it('FE-MOB-MDUS-009: the currency row shows the symbol and the picker saves the chosen code', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ default_currency: 'USD' }); + render(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('button', { name: 'USD $' })); + const sheet = await screen.findByRole('dialog'); + await user.click(within(sheet).getByRole('button', { name: 'EUR €' })); + + await waitFor(() => expect(puts).toEqual([{ default_currency: 'EUR' }])); + await waitFor(() => expect(screen.getByRole('button', { name: 'EUR €' })).toBeInTheDocument()); + }); + + it('FE-MOB-MDUS-010: the blur-booking-codes toggle saves a boolean default', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + const toggle = screen.getByRole('switch', { name: 'Blur Booking Codes' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await user.click(toggle); + + await waitFor(() => expect(puts).toEqual([{ blur_booking_codes: true }])); + await waitFor(() => expect(screen.getByRole('switch', { name: 'Blur Booking Codes' })).toHaveAttribute('aria-checked', 'true')); + }); + + it('FE-MOB-MDUS-011: the tile URL is saved on blur and handed to the preview map', async () => { + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + // Typed through fireEvent: the {z}/{x}/{y} placeholders collide with userEvent's key syntax + const input = screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); + fireEvent.change(input, { target: { value: 'https://tiles.example.org/{z}/{x}/{y}.png' } }); + fireEvent.blur(input); + + await waitFor(() => expect(puts).toEqual([{ map_tile_url: 'https://tiles.example.org/{z}/{x}/{y}.png' }])); + expect(screen.getByTestId('map-preview')).toHaveAttribute('data-tile', 'https://tiles.example.org/{z}/{x}/{y}.png'); + }); + + it('FE-MOB-MDUS-012: the tile preset sheet fills the URL field and saves it', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('button', { name: 'Select template...' })); + const sheet = await screen.findByRole('dialog'); + await user.click(within(sheet).getByRole('button', { name: 'CartoDB Dark' })); + + const url = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'; + await waitFor(() => expect(puts).toEqual([{ map_tile_url: url }])); + expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue(url); + await waitFor(() => expect(screen.getByRole('button', { name: 'CartoDB Dark' })).toBeInTheDocument()); + }); + + it('FE-MOB-MDUS-013: resetting the tile URL clears the input as well', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }); + render(); + await screen.findByText('Default User Settings'); + + expect(screen.getByRole('button', { name: 'OpenStreetMap DE' })).toBeInTheDocument(); + await user.click(resetLink('Map Template')); + + await waitFor(() => expect(puts).toEqual([{ map_tile_url: null }])); + await waitFor(() => expect(screen.getByPlaceholderText('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png')).toHaveValue('')); + }); + + it('FE-MOB-MDUS-014: leaflet hides the GL-only token and style fields', async () => { + render(); + await screen.findByText('Default User Settings'); + + expect(screen.getByRole('tab', { name: 'Standard (free)' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.queryByText('Map style')).not.toBeInTheDocument(); + expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-015: switching to Mapbox stores the provider together with its own style slot', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('tab', { name: 'Mapbox (3D)' })); + + await waitFor(() => expect(puts).toEqual([{ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }])); + expect(await screen.findByText('Shared Mapbox token')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: '3D buildings & terrain' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'High-quality mode' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-MDUS-016: switching to MapLibre stores the OpenFreeMap default in maplibre_style', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults(); + render(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('tab', { name: 'MapLibre (OpenFreeMap)' })); + + await waitFor(() => expect(puts).toEqual([{ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }])); + // MapLibre needs no shared token + expect(screen.queryByText('Shared Mapbox token')).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'OpenFreeMap Liberty' })).toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-017: a Mapbox default holding an OpenFreeMap style falls back to the Mapbox standard', async () => { + stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: OFM_LIBERTY }); + render(); + await screen.findByText('Default User Settings'); + + expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument(); + expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-018: the shared Mapbox token is stored on blur and cleared by its reset link', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.old' }); + render(); + await screen.findByText('Shared Mapbox token'); + + const input = screen.getByPlaceholderText('pk.eyJ…'); + expect(input).toHaveValue('pk.old'); + await user.clear(input); + await user.type(input, 'pk.new'); + input.blur(); + await waitFor(() => expect(puts).toEqual([{ mapbox_access_token: 'pk.new' }])); + + await user.click(resetLink('Shared Mapbox token')); + await waitFor(() => expect(puts).toHaveLength(2)); + expect(puts[1]).toEqual({ mapbox_access_token: null }); + await waitFor(() => expect(screen.getByPlaceholderText('pk.eyJ…')).toHaveValue('')); + }); + + it('FE-MOB-MDUS-019: a hand-typed MapLibre style is normalised to OpenFreeMap on blur', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_provider: 'maplibre-gl', maplibre_style: OFM_LIBERTY }); + render(); + await screen.findByText('Map style'); + + const input = screen.getByDisplayValue(OFM_LIBERTY); + await user.clear(input); + await user.type(input, 'https://example.com/custom.json'); + input.blur(); + + await waitFor(() => expect(puts).toEqual([{ maplibre_style: OFM_LIBERTY }])); + expect(input).toHaveValue(OFM_LIBERTY); + }); + + it('FE-MOB-MDUS-020: the style sheet writes the picked preset into the active provider slot', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: MAPBOX_STANDARD }); + render(); + await screen.findByText('Map style'); + + await user.click(screen.getByRole('button', { name: 'Mapbox Standard' })); + const sheet = await screen.findByRole('dialog'); + await user.click(within(sheet).getByRole('button', { name: 'Navigation Night' })); + + const url = 'mapbox://styles/mapbox/navigation-night-v1'; + await waitFor(() => expect(puts).toEqual([{ mapbox_style: url }])); + expect(screen.getByDisplayValue(url)).toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-021: resetting the style restores the provider default in the field', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_style: 'mapbox://styles/mapbox/dark-v11' }); + render(); + await screen.findByText('Map style'); + + await user.click(resetLink('Map style')); + + await waitFor(() => expect(puts).toEqual([{ mapbox_style: null }])); + await waitFor(() => expect(screen.getByDisplayValue(MAPBOX_STANDARD)).toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Mapbox Standard' })).toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-022: the Mapbox 3D and quality toggles save their own keys', async () => { + const user = userEvent.setup(); + const { puts } = stubDefaults({ map_provider: 'mapbox-gl', mapbox_3d_enabled: true, mapbox_quality_mode: false }); + render(); + await screen.findByText('Map style'); + + await user.click(screen.getByRole('switch', { name: '3D buildings & terrain' })); + await waitFor(() => expect(screen.getByRole('switch', { name: '3D buildings & terrain' })).toHaveAttribute('aria-checked', 'false')); + await user.click(screen.getByRole('switch', { name: 'High-quality mode' })); + + await waitFor(() => expect(puts).toEqual([{ mapbox_3d_enabled: false }, { mapbox_quality_mode: true }])); + }); + + it('FE-MOB-MDUS-023: a rejected save surfaces the request error instead of a success toast', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/default-user-settings', () => HttpResponse.json({})), + http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + withToast(); + await screen.findByText('Default User Settings'); + + await user.click(screen.getByRole('tab', { name: 'Dark' })); + + expect(await screen.findByText(/Request failed with status code 500/)).toBeInTheDocument(); + expect(screen.queryByText('Default saved')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MDUS-024: a rejected reset keeps the value and reports the failure', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/default-user-settings', () => HttpResponse.json({ temperature_unit: 'celsius' })), + http.put('/api/admin/default-user-settings', () => HttpResponse.json({ error: 'nope' }, { status: 503 })), + ); + withToast(); + await screen.findByText('Default User Settings'); + + await user.click(resetLink('Temperature Unit')); + + expect(await screen.findByText(/Request failed with status code 503/)).toBeInTheDocument(); + expect(screen.queryByText('Reset to built-in default')).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: '°C Celsius' })).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminDevNotificationsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminDevNotificationsPanel.test.tsx new file mode 100644 index 000000000..5edb0281a --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminDevNotificationsPanel.test.tsx @@ -0,0 +1,337 @@ +// FE-MOB-ADEV-001 to FE-MOB-ADEV-015 +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildAdmin } from '../../../helpers/factories'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminDevNotificationsPanel from '../../../../src/mobile/screens/admin/MAdminDevNotificationsPanel'; + +const ADMIN = buildAdmin({ id: 7, username: 'testadmin', email: 'admin@example.com' }); + +function renderPanel() { + return render( + <> + + + , + ); +} + +/** Captures every POST body the panel sends while a test runs. */ +function captureSends() { + const bodies: Record[] = []; + server.use( + http.post('/api/admin/dev/test-notification', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ success: true }); + }), + ); + return bodies; +} + +function clickButton(user: ReturnType, label: string) { + return user.click(screen.getByText(label).closest('button')!); +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { isAuthenticated: true, user: ADMIN }); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminDevNotificationsPanel', () => { + it('FE-MOB-ADEV-001: renders the dev badge and all four sections once the data is in', async () => { + renderPanel(); + + expect(screen.getByText('DEV ONLY')).toBeInTheDocument(); + expect(screen.getByText('Notification Testing')).toBeInTheDocument(); + expect(screen.getByText('Type Testing')).toBeInTheDocument(); + expect(screen.getByText('Admin-Scoped Events')).toBeInTheDocument(); + await screen.findByText('Trip-Scoped Events'); + await screen.findByText('User-Scoped Events'); + }); + + it('FE-MOB-ADEV-002: the trip selector lists the trips from the API and preselects the first', async () => { + renderPanel(); + await screen.findByText('Trip-Scoped Events'); + + const [tripSelect] = screen.getAllByRole('combobox'); + const labels = Array.from(tripSelect.querySelectorAll('option')).map((o) => o.textContent); + expect(labels).toContain('Paris Adventure'); + expect(labels).toContain('Tokyo Trip'); + expect((tripSelect as HTMLSelectElement).value).toBe( + (tripSelect.querySelector('option') as HTMLOptionElement).value, + ); + }); + + it('FE-MOB-ADEV-003: the user selector shows username and email of every user', async () => { + renderPanel(); + await screen.findByText('User-Scoped Events'); + + const userSelect = screen.getAllByRole('combobox')[1]; + const labels = Array.from(userSelect.querySelectorAll('option')).map((o) => o.textContent ?? ''); + expect(labels).toContain('admin (admin@example.com)'); + expect(labels).toContain('alice (alice@example.com)'); + }); + + it('FE-MOB-ADEV-004: the type-testing buttons send the documented payloads', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Type Testing'); + + await clickButton(user, 'Simple → Me'); + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ event: 'test_simple', scope: 'user', targetId: 7, params: {} }); + + await clickButton(user, 'Boolean → Me'); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[1]).toMatchObject({ + event: 'test_boolean', + scope: 'user', + targetId: 7, + inApp: { + type: 'boolean', + positiveCallback: { action: 'test_approve', payload: {} }, + negativeCallback: { action: 'test_deny', payload: {} }, + }, + }); + + await clickButton(user, 'Navigate → Me'); + await waitFor(() => expect(bodies).toHaveLength(3)); + expect(bodies[2]).toMatchObject({ event: 'test_navigate', scope: 'user', targetId: 7 }); + + await clickButton(user, 'Simple → All Admins'); + await waitFor(() => expect(bodies).toHaveLength(4)); + expect(bodies[3]).toMatchObject({ event: 'test_simple', scope: 'admin', targetId: 0 }); + }); + + it('FE-MOB-ADEV-005: every trip-scoped button targets the selected trip with its own params', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Trip-Scoped Events'); + + const [tripSelect] = screen.getAllByRole('combobox'); + const tripId = Number((tripSelect as HTMLSelectElement).value); + const tripTitle = tripSelect.querySelector('option')!.textContent; + + await clickButton(user, 'booking_change'); + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ + event: 'booking_change', + scope: 'trip', + targetId: tripId, + params: { + actor: 'testadmin', + trip: tripTitle, + booking: 'Test Hotel', + type: 'hotel', + tripId: String(tripId), + }, + }); + + await clickButton(user, 'trip_reminder'); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[1]).toEqual({ + event: 'trip_reminder', + scope: 'trip', + targetId: tripId, + params: { trip: tripTitle, tripId: String(tripId) }, + }); + + await clickButton(user, 'photos_shared'); + await waitFor(() => expect(bodies).toHaveLength(3)); + expect(bodies[2]).toMatchObject({ event: 'photos_shared', params: { count: '5' } }); + + await clickButton(user, 'collab_message'); + await waitFor(() => expect(bodies).toHaveLength(4)); + expect(bodies[3]).toMatchObject({ + event: 'collab_message', + params: { preview: 'This is a test message preview.' }, + }); + + await clickButton(user, 'packing_tagged'); + await waitFor(() => expect(bodies).toHaveLength(5)); + expect(bodies[4]).toMatchObject({ event: 'packing_tagged', params: { category: 'Clothing' } }); + }); + + it('FE-MOB-ADEV-006: the user-scoped buttons target the selected recipient', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('User-Scoped Events'); + + const userSelect = screen.getAllByRole('combobox')[1] as HTMLSelectElement; + const recipientId = Number(userSelect.value); + + await clickButton(user, 'trip_invite'); + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toMatchObject({ + event: 'trip_invite', + scope: 'user', + targetId: recipientId, + params: { actor: 'testadmin', invitee: 'admin@example.com' }, + }); + + await clickButton(user, 'vacay_invite'); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[1]).toEqual({ + event: 'vacay_invite', + scope: 'user', + targetId: recipientId, + params: { actor: 'testadmin', planId: '1' }, + }); + }); + + it('FE-MOB-ADEV-007: the admin-scoped button announces a fake version', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Admin-Scoped Events'); + + await clickButton(user, 'version_available'); + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ + event: 'version_available', + scope: 'admin', + targetId: 0, + params: { version: '9.9.9-test' }, + }); + }); + + it('FE-MOB-ADEV-008: changing the trip selector changes the payload target', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Trip-Scoped Events'); + + const [tripSelect] = screen.getAllByRole('combobox'); + const tokyo = Array.from(tripSelect.querySelectorAll('option')).find( + (o) => o.textContent === 'Tokyo Trip', + )!; + await user.selectOptions(tripSelect, tokyo.value); + await clickButton(user, 'trip_reminder'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toMatchObject({ targetId: Number(tokyo.value), params: { trip: 'Tokyo Trip' } }); + }); + + it('FE-MOB-ADEV-009: changing the user selector changes the recipient', async () => { + const bodies = captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('User-Scoped Events'); + + const userSelect = screen.getAllByRole('combobox')[1]; + const alice = Array.from(userSelect.querySelectorAll('option')).find( + (o) => o.textContent?.startsWith('alice'), + )!; + await user.selectOptions(userSelect, alice.value); + await clickButton(user, 'trip_invite'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toMatchObject({ + targetId: Number(alice.value), + params: { invitee: 'alice@example.com' }, + }); + }); + + it('FE-MOB-ADEV-010: a successful send raises a toast naming the fired button', async () => { + captureSends(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Type Testing'); + + await clickButton(user, 'Simple → Me'); + expect(await screen.findByText('Sent: simple-me')).toBeInTheDocument(); + }); + + it('FE-MOB-ADEV-011: a failing send raises an error toast', async () => { + server.use( + http.post('/api/admin/dev/test-notification', () => HttpResponse.json({}, { status: 500 })), + ); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Type Testing'); + + await clickButton(user, 'Simple → Me'); + expect(await screen.findByText(/failed|error/i)).toBeInTheDocument(); + }); + + it('FE-MOB-ADEV-012: every button is disabled while a send is in flight', async () => { + server.use( + http.post('/api/admin/dev/test-notification', async () => { + await new Promise(() => {}); + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('Type Testing'); + + void clickButton(user, 'Simple → Me'); + + await waitFor(() => { + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThan(1); + buttons.forEach((btn) => expect(btn).toBeDisabled()); + }); + // The fired button shows its own spinner + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + }); + + it('FE-MOB-ADEV-013: the scoped sections stay hidden when both lookups fail', async () => { + server.use( + http.get('/api/trips', () => HttpResponse.json({}, { status: 500 })), + http.get('/api/admin/users', () => HttpResponse.json({}, { status: 500 })), + ); + renderPanel(); + + await screen.findByText('Type Testing'); + await waitFor(() => expect(screen.queryAllByRole('combobox')).toHaveLength(0)); + expect(screen.queryByText('Trip-Scoped Events')).not.toBeInTheDocument(); + expect(screen.queryByText('User-Scoped Events')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ADEV-014: bare arrays are accepted as well as the wrapped payloads', async () => { + server.use( + http.get('/api/trips', () => HttpResponse.json([{ id: 55, title: 'Bare Trip' }])), + http.get('/api/admin/users', () => + HttpResponse.json([{ id: 66, username: 'bare', email: 'bare@example.com' }]), + ), + ); + renderPanel(); + await screen.findByText('Trip-Scoped Events'); + + const [tripSelect, userSelect] = screen.getAllByRole('combobox') as HTMLSelectElement[]; + expect(tripSelect.value).toBe('55'); + expect(screen.getByText('Bare Trip')).toBeInTheDocument(); + expect(userSelect.value).toBe('66'); + expect(screen.getByText('bare (bare@example.com)')).toBeInTheDocument(); + }); + + it('FE-MOB-ADEV-015: without trips or a signed-in user the invite falls back to defaults', async () => { + const bodies = captureSends(); + server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] }))); + resetAllStores(); + const user = userEvent.setup(); + renderPanel(); + await screen.findByText('User-Scoped Events'); + + await clickButton(user, 'trip_invite'); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toMatchObject({ + event: 'trip_invite', + params: { actor: 'Admin', trip: 'Test Trip', tripId: '0' }, + }); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx new file mode 100644 index 000000000..f8ec8c654 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminGitHubPanel.test.tsx @@ -0,0 +1,279 @@ +// FE-MOB-AGH-001 to FE-MOB-AGH-018 +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import MAdminGitHubPanel from '../../../../src/mobile/screens/admin/MAdminGitHubPanel'; + +interface ReleaseOverrides { + id?: number; + tag_name?: string; + name?: string | null; + body?: string | null; + published_at?: string | null; + created_at?: string; + prerelease?: boolean; + author?: { login: string } | null; +} + +function buildRelease(overrides: ReleaseOverrides = {}) { + return { + id: 1, + tag_name: 'v1.0.0', + name: 'Initial Release', + body: null, + published_at: '2025-01-15T12:00:00Z', + created_at: '2025-01-15T12:00:00Z', + prerelease: false, + author: { login: 'mauriceboe' }, + ...overrides, + }; +} + +const PAGE_1 = Array.from({ length: 10 }, (_, i) => buildRelease({ id: i + 1, tag_name: `v1.${i}.0` })); +const PAGE_2 = Array.from({ length: 3 }, (_, i) => buildRelease({ id: 100 + i, tag_name: `v0.${i}.0` })); + +function releasesRespondWith(payload: unknown) { + server.use(http.get('/api/admin/github-releases', () => HttpResponse.json(payload))); +} + +beforeEach(() => { + resetAllStores(); + releasesRespondWith([]); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +async function renderPanel(props: { isPrerelease?: boolean } = {}) { + const view = render(); + await waitFor(() => expect(document.querySelector('.animate-spin')).not.toBeInTheDocument()); + return view; +} + +describe('MAdminGitHubPanel', () => { + it('FE-MOB-AGH-001: renders the six support cards with their external hrefs', async () => { + await renderPanel(); + + expect(screen.getByText('Ko-fi').closest('a')).toHaveAttribute('href', 'https://ko-fi.com/mauriceboe'); + expect(screen.getByText('Buy Me a Coffee').closest('a')).toHaveAttribute( + 'href', + 'https://buymeacoffee.com/mauriceboe', + ); + const discord = screen.getByText('Discord').closest('a')!; + expect(discord).toHaveAttribute('href', 'https://discord.gg/NhZBDSd4qW'); + expect(discord).toHaveAttribute('target', '_blank'); + expect(discord).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('Report a Bug')).toBeInTheDocument(); + expect(screen.getByText('Feature Request')).toBeInTheDocument(); + expect(screen.getByText('Wiki').closest('a')).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK/wiki'); + expect(screen.getAllByText('Helps me keep building TREK')).toHaveLength(2); + }); + + it('FE-MOB-AGH-002: shows a spinner while the releases request is in flight', () => { + server.use( + http.get('/api/admin/github-releases', async () => { + await new Promise(() => {}); + return HttpResponse.json([]); + }), + ); + render(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Release History')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-003: renders the error card with the axios message when the request fails', async () => { + server.use( + http.get('/api/admin/github-releases', () => HttpResponse.json({ message: 'boom' }, { status: 500 })), + ); + render(); + + await screen.findByText('Failed to load releases'); + expect(screen.getByText(/500/)).toBeInTheDocument(); + expect(screen.queryByText('Release History')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-004: renders the timeline header with the repo subtitle and the GitHub link', async () => { + releasesRespondWith([buildRelease()]); + await renderPanel(); + + expect(screen.getByText('Release History')).toBeInTheDocument(); + expect(screen.getByText('Latest updates from mauriceboe/TREK')).toBeInTheDocument(); + expect(screen.getByText('GitHub').closest('a')).toHaveAttribute( + 'href', + 'https://github.com/mauriceboe/TREK/releases', + ); + }); + + it('FE-MOB-AGH-005: marks only the first release as latest and shows the release name and author', async () => { + releasesRespondWith([ + buildRelease({ id: 1, tag_name: 'v2.0.0', name: 'Big Bang' }), + buildRelease({ id: 2, tag_name: 'v1.9.0', name: 'v1.9.0' }), + ]); + await renderPanel(); + + expect(screen.getAllByText('Latest')).toHaveLength(1); + expect(screen.getByText('v2.0.0')).toBeInTheDocument(); + // name === tag_name on the second release, so it is not repeated + expect(screen.getByText('Big Bang')).toBeInTheDocument(); + expect(screen.getAllByText('v1.9.0')).toHaveLength(1); + expect(screen.getAllByText('by mauriceboe')).toHaveLength(2); + }); + + it('FE-MOB-AGH-006: falls back to created_at when published_at is null and hides a missing author', async () => { + releasesRespondWith([ + buildRelease({ published_at: null, created_at: '2024-03-02T08:00:00Z', author: null, name: null }), + ]); + await renderPanel(); + + expect(screen.getByText('Mar 2, 2024')).toBeInTheDocument(); + expect(screen.queryByText(/by /)).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-007: hides prereleases unless the panel runs in prerelease mode', async () => { + releasesRespondWith([ + buildRelease({ id: 1, tag_name: 'v3.0.0-beta.1', prerelease: true }), + buildRelease({ id: 2, tag_name: 'v2.9.0' }), + ]); + await renderPanel(); + + expect(screen.queryByText('v3.0.0-beta.1')).not.toBeInTheDocument(); + expect(screen.getByText('v2.9.0')).toBeInTheDocument(); + }); + + it('FE-MOB-AGH-008: prerelease mode shows the pre-release badge', async () => { + releasesRespondWith([buildRelease({ id: 1, tag_name: 'v3.0.0-beta.1', prerelease: true })]); + await renderPanel({ isPrerelease: true }); + + expect(screen.getByText('v3.0.0-beta.1')).toBeInTheDocument(); + expect(screen.getByText('Pre-release')).toBeInTheDocument(); + }); + + it('FE-MOB-AGH-009: no toggle is offered for a release without a body', async () => { + releasesRespondWith([buildRelease({ body: null })]); + await renderPanel(); + + expect(screen.queryByText('Show details')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-010: expands and collapses the release notes', async () => { + releasesRespondWith([buildRelease({ body: '- Fixed a bug' })]); + const user = userEvent.setup(); + await renderPanel(); + + expect(screen.queryByText('Fixed a bug')).not.toBeInTheDocument(); + await user.click(screen.getByText('Show details')); + expect(await screen.findByText('Fixed a bug')).toBeInTheDocument(); + + await user.click(screen.getByText('Hide details')); + await waitFor(() => expect(screen.queryByText('Fixed a bug')).not.toBeInTheDocument()); + expect(screen.getByText('Show details')).toBeInTheDocument(); + }); + + it('FE-MOB-AGH-011: renders headings, list items and paragraphs from the markdown body', async () => { + releasesRespondWith([ + buildRelease({ + body: '## Highlights\n- first item\n- second item\n\n### Details\nA plain paragraph.', + }), + ]); + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByText('Show details')); + + expect((await screen.findByText('Highlights')).tagName).toBe('H3'); + expect(screen.getByText('Details').tagName).toBe('H4'); + expect(screen.getByText('first item').closest('li')).toBeInTheDocument(); + expect(screen.getByText('second item').closest('li')).toBeInTheDocument(); + expect(screen.getByText('A plain paragraph.').tagName).toBe('P'); + }); + + it('FE-MOB-AGH-012: renders bold and inline code inside list items', async () => { + releasesRespondWith([buildRelease({ body: '- **bold text**\n- `inline code`' })]); + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByText('Show details')); + + expect((await screen.findByText('bold text')).tagName).toBe('STRONG'); + expect(screen.getByText('inline code').tagName).toBe('CODE'); + }); + + it('FE-MOB-AGH-013: keeps http links and rewrites unsafe link targets to #', async () => { + releasesRespondWith([ + buildRelease({ body: '- [click here](https://example.com)\n- [evil](javascript:alert(1))' }), + ]); + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByText('Show details')); + + const safe = await screen.findByText('click here'); + expect(safe.tagName).toBe('A'); + expect(safe).toHaveAttribute('href', 'https://example.com'); + expect(safe).toHaveAttribute('rel', 'noopener noreferrer'); + expect(screen.getByText('evil')).toHaveAttribute('href', '#'); + }); + + it('FE-MOB-AGH-014: escapes html in the release body instead of rendering it', async () => { + releasesRespondWith([buildRelease({ body: 'plain text' })]); + const user = userEvent.setup(); + await renderPanel(); + await user.click(screen.getByText('Show details')); + + expect(await screen.findByText('plain text')).toBeInTheDocument(); + expect(document.querySelector('img')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-015: hides "Load more" when the first page is not full', async () => { + releasesRespondWith(PAGE_2); + await renderPanel(); + + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-016: "Load more" appends the second page and then disappears', async () => { + server.use( + http.get('/api/admin/github-releases', ({ request }) => { + const page = new URL(request.url).searchParams.get('page'); + return HttpResponse.json(page === '2' ? PAGE_2 : PAGE_1); + }), + ); + const user = userEvent.setup(); + await renderPanel(); + + expect(screen.getAllByText(/^v1\.\d\.0$/)).toHaveLength(10); + await user.click(screen.getByText('Load more')); + + expect(await screen.findByText('v0.0.0')).toBeInTheDocument(); + expect(screen.getAllByText(/^v[01]\.\d\.0$/)).toHaveLength(13); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AGH-017: a failing "Load more" swaps the timeline for the error card', async () => { + let calls = 0; + server.use( + http.get('/api/admin/github-releases', () => { + calls += 1; + if (calls === 1) return HttpResponse.json(PAGE_1); + return HttpResponse.json({ message: 'nope' }, { status: 500 }); + }), + ); + const user = userEvent.setup(); + await renderPanel(); + + await user.click(screen.getByText('Load more')); + await screen.findByText('Failed to load releases'); + }); + + it('FE-MOB-AGH-018: a non-array response is treated as an empty release list', async () => { + releasesRespondWith({ message: 'rate limited' }); + await renderPanel(); + + expect(screen.getByText('Release History')).toBeInTheDocument(); + expect(screen.queryByText('Load more')).not.toBeInTheDocument(); + expect(screen.queryByText('Latest')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminMcpTokensPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminMcpTokensPanel.test.tsx new file mode 100644 index 000000000..f089e5edd --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminMcpTokensPanel.test.tsx @@ -0,0 +1,293 @@ +// FE-MOB-MCPTOK-001 to FE-MOB-MCPTOK-014 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminMcpTokensPanel from '../../../../src/mobile/screens/admin/MAdminMcpTokensPanel'; + +const TOKEN_1 = { + id: 1, + name: 'CI Token', + token_prefix: 'trek_abc', + created_at: '2025-01-15T00:00:00Z', + last_used_at: null, + user_id: 10, + username: 'alice', +}; + +const TOKEN_2 = { + id: 2, + name: 'Ops Token', + token_prefix: 'trek_xyz', + created_at: '2025-03-01T00:00:00Z', + last_used_at: '2025-04-01T00:00:00Z', + user_id: 11, + username: 'bob', +}; + +function buildSession(overrides: Record = {}) { + return { + id: 1, + client_id: 'cid', + client_name: 'Claude Desktop', + user_id: 10, + username: 'alice', + scopes: ['trips:read', 'budget:read'], + access_token_expires_at: '2025-02-01T00:00:00Z', + refresh_token_expires_at: '2025-03-01T00:00:00Z', + created_at: '2025-01-01T00:00:00Z', + ...overrides, + }; +} + +function renderPanel() { + return render( + <> + + + , + ); +} + +// The trash icons carry title="Delete" too — the confirm sheet's button is the +// one without a title attribute. +function confirmButton(label: string): HTMLElement { + const candidates = screen.getAllByRole('button', { name: label }); + const withoutTitle = candidates.find((b) => !b.getAttribute('title')); + return withoutTitle ?? candidates[candidates.length - 1]; +} + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminMcpTokensPanel', () => { + it('FE-MOB-MCPTOK-001: renders the section header and both sub-section titles', async () => { + renderPanel(); + + expect(screen.getByText('MCP Access')).toBeInTheDocument(); + expect(screen.getByText('Manage OAuth sessions and API tokens across all users')).toBeInTheDocument(); + expect(screen.getByText('OAuth Sessions')).toBeInTheDocument(); + expect(screen.getByText('API Tokens')).toBeInTheDocument(); + await screen.findByText('No MCP tokens have been created yet'); + }); + + it('FE-MOB-MCPTOK-002: shows spinners while both requests are in flight', async () => { + server.use( + http.get('/api/admin/mcp-tokens', async () => { + await new Promise(() => {}); + return HttpResponse.json({ tokens: [] }); + }), + http.get('/api/admin/oauth-sessions', async () => { + await new Promise(() => {}); + return HttpResponse.json({ sessions: [] }); + }), + ); + renderPanel(); + + expect(document.querySelectorAll('.animate-spin')).toHaveLength(2); + expect(screen.queryByText('No MCP tokens have been created yet')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-003: renders both empty states when nothing is configured', async () => { + renderPanel(); + + await screen.findByText('No active OAuth sessions'); + expect(screen.getByText('No MCP tokens have been created yet')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-004: token rows show name, prefix, owner and last-used date', async () => { + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] }))); + renderPanel(); + + await screen.findByText('CI Token'); + expect(screen.getByText('Ops Token')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + expect(screen.getByText(/trek_abc/)).toBeInTheDocument(); + expect(screen.getByText(/trek_xyz/)).toBeInTheDocument(); + // last_used_at null → "Never", otherwise the formatted date + expect(screen.getByText('Never')).toBeInTheDocument(); + expect(screen.getByText('4/1/2025')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-005: session rows show client, user and scope chips', async () => { + server.use(http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [buildSession()] }))); + renderPanel(); + + await screen.findByText('Claude Desktop'); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('trips:read')).toBeInTheDocument(); + expect(screen.getByText('budget:read')).toBeInTheDocument(); + expect(screen.getByText('1/1/2025')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-006: scopes beyond the preview stay hidden until expanded', async () => { + const user = userEvent.setup(); + const scopes = [ + 'trips:read', 'trips:write', 'places:read', 'places:write', + 'budget:read', 'budget:write', 'packing:read', + ]; + server.use( + http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ sessions: [buildSession({ scopes })] })), + ); + renderPanel(); + + await screen.findByText('Claude Desktop'); + expect(screen.queryByText('packing:read')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: '+1 more' })); + expect(screen.getByText('packing:read')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'show less' })); + expect(screen.queryByText('packing:read')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-007: no expand button when scopes fit into the preview', async () => { + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ sessions: [buildSession({ scopes: ['trips:read'] })] }), + ), + ); + renderPanel(); + + await screen.findByText('Claude Desktop'); + expect(screen.queryByText(/more$/)).not.toBeInTheDocument(); + expect(screen.queryByText('show less')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-008: token trash opens the delete confirm sheet', async () => { + const user = userEvent.setup(); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] }))); + renderPanel(); + + await screen.findByText('CI Token'); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('dialog', { name: 'Delete Token' })).toBeInTheDocument(); + expect( + screen.getByText('This will revoke the token immediately. The user will lose MCP access through this token.'), + ).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-009: cancelling the confirm sheet keeps the token', async () => { + const user = userEvent.setup(); + server.use(http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] }))); + renderPanel(); + + await screen.findByText('CI Token'); + await user.click(screen.getByRole('button', { name: 'Delete' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByText('CI Token')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-010: confirming removes the token and toasts', async () => { + const user = userEvent.setup(); + let deletedId = ''; + server.use( + http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1, TOKEN_2] })), + http.delete('/api/admin/mcp-tokens/:id', ({ params }) => { + deletedId = String(params.id); + return HttpResponse.json({ success: true }); + }), + ); + renderPanel(); + + await screen.findByText('CI Token'); + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + await user.click(confirmButton('Delete')); + + await waitFor(() => expect(screen.queryByText('CI Token')).not.toBeInTheDocument()); + expect(deletedId).toBe('1'); + expect(screen.getByText('Ops Token')).toBeInTheDocument(); + await screen.findByText('Token deleted'); + }); + + it('FE-MOB-MCPTOK-011: a failing delete keeps the token and shows the error toast', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ tokens: [TOKEN_1] })), + http.delete('/api/admin/mcp-tokens/:id', () => HttpResponse.json({ error: 'nope' }, { status: 403 })), + ); + renderPanel(); + + await screen.findByText('CI Token'); + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + await user.click(confirmButton('Delete')); + + await screen.findByText('Failed to delete token'); + expect(screen.getByText('CI Token')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-012: revoking a session removes it and toasts', async () => { + const user = userEvent.setup(); + let revokedId = ''; + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ sessions: [buildSession({ id: 5, client_name: 'Revoke Me' })] }), + ), + http.delete('/api/admin/oauth-sessions/:id', ({ params }) => { + revokedId = String(params.id); + return HttpResponse.json({ success: true }); + }), + ); + renderPanel(); + + await screen.findByText('Revoke Me'); + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + expect(screen.getByRole('dialog', { name: 'Revoke Session' })).toBeInTheDocument(); + + // Cancelling leaves the session untouched; reopening still works. + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(revokedId).toBe(''); + + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + await user.click(confirmButton('Delete')); + + await waitFor(() => expect(screen.queryByText('Revoke Me')).not.toBeInTheDocument()); + expect(revokedId).toBe('5'); + await screen.findByText('Session revoked'); + }); + + it('FE-MOB-MCPTOK-013: a failing revoke keeps the session and shows the error toast', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/oauth-sessions', () => + HttpResponse.json({ sessions: [buildSession({ id: 6, client_name: 'Error Session' })] }), + ), + http.delete('/api/admin/oauth-sessions/:id', () => HttpResponse.json({ error: 'nope' }, { status: 403 })), + ); + renderPanel(); + + await screen.findByText('Error Session'); + await user.click(screen.getAllByRole('button', { name: 'Delete' })[0]); + await user.click(confirmButton('Delete')); + + await screen.findByText('Failed to revoke session'); + expect(screen.getByText('Error Session')).toBeInTheDocument(); + }); + + it('FE-MOB-MCPTOK-014: both load failures toast and fall back to the empty states', async () => { + server.use( + http.get('/api/admin/mcp-tokens', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + http.get('/api/admin/oauth-sessions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderPanel(); + + await screen.findByText('Failed to load tokens'); + expect(screen.getByText('Failed to load OAuth sessions')).toBeInTheDocument(); + expect(screen.getByText('No MCP tokens have been created yet')).toBeInTheDocument(); + expect(screen.getByText('No active OAuth sessions')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx b/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx new file mode 100644 index 000000000..89e115ac8 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminNotificationsSection.test.tsx @@ -0,0 +1,540 @@ +// FE-MOB-ANOTIF-001 to FE-MOB-ANOTIF-031 +import { useState } from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useTranslation } from '../../../../src/i18n'; +import type { useAdmin } from '../../../../src/pages/admin/useAdmin'; +import MAdminNotificationsSection from '../../../../src/mobile/screens/admin/MAdminNotificationsSection'; + +// The per-event matrix loads its own data and has its own spec — stub it so the +// assertions here only see the section's own cards. +vi.mock('../../../../src/mobile/screens/admin/MAdminNotifyMatrix', () => ({ + default: () =>
, +})); + +const MASK = '••••••••'; + +interface HarnessProps { + initial?: Record; + smtpLoaded?: boolean; + toast?: { success: ReturnType; error: ReturnType }; + setTripRemindersEnabled?: (value: boolean) => void; +} + +function buildToast() { + return { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; +} + +// The section is a controlled child of useAdmin — this harness owns the same +// smtpValues state so setSmtpValues updaters really re-render the card. +function Harness({ initial = {}, smtpLoaded = true, toast = buildToast(), setTripRemindersEnabled = vi.fn() }: HarnessProps) { + const { t } = useTranslation(); + const [smtpValues, setSmtpValues] = useState>(initial); + const admin = { + toast, + smtpValues, + setSmtpValues, + smtpLoaded, + setTripRemindersEnabled, + } as unknown as ReturnType; + return ; +} + +/** Records the body of every PUT /auth/app-settings the section sends. */ +function captureAppSettings() { + const bodies: Record[] = []; + server.use( + http.put('/api/auth/app-settings', async ({ request }) => { + bodies.push((await request.json()) as Record); + return HttpResponse.json({ success: true }); + }), + ); + return bodies; +} + +function failAppSettings() { + server.use(http.put('/api/auth/app-settings', () => HttpResponse.json({}, { status: 500 }))); +} + +/** The two password inputs are, in DOM order, the SMTP password and the ntfy token. */ +function ntfyTokenInput() { + return Array.from(document.querySelectorAll('input[type="password"]'))[1]; +} + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminNotificationsSection', () => { + it('FE-MOB-ANOTIF-001: renders every channel card and prefills the SMTP fields', () => { + render( + , + ); + + expect(screen.getByText('Email (SMTP)')).toBeInTheDocument(); + expect(screen.getByText('Webhook')).toBeInTheDocument(); + expect(screen.getByText('Ntfy')).toBeInTheDocument(); + expect(screen.getByText('In-App')).toBeInTheDocument(); + expect(screen.getByText('Trip Reminders')).toBeInTheDocument(); + expect(screen.getByText('Admin Webhook')).toBeInTheDocument(); + expect(screen.getByText('Admin Ntfy')).toBeInTheDocument(); + expect(screen.getByTestId('notify-matrix')).toBeInTheDocument(); + + expect(screen.getByPlaceholderText('mail.example.com')).toHaveValue('mail.test'); + expect(screen.getByPlaceholderText('587')).toHaveValue('2525'); + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'In-App' })).toBeDisabled(); + }); + + it('FE-MOB-ANOTIF-002: hides the credential fields until the settings have loaded', () => { + render(); + + expect(screen.queryByPlaceholderText('mail.example.com')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('https://ntfy.sh')).not.toBeInTheDocument(); + expect(screen.getByText('Email (SMTP)')).toBeInTheDocument(); + }); + + it('FE-MOB-ANOTIF-003: derives the active channels from the plural setting', () => { + render(); + + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Ntfy' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-ANOTIF-004: falls back to the legacy singular setting', () => { + render(); + + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-ANOTIF-005: treats a missing setting as no active channel', () => { + render(); + + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Ntfy' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-ANOTIF-006: enabling a channel saves the CSV and keeps unknown channel ids', async () => { + const bodies = captureAppSettings(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Email (SMTP)' })); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ notification_channels: 'email,webhook,pushover' }); + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-ANOTIF-007: disabling the last channel stores "none"', async () => { + const bodies = captureAppSettings(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Ntfy' })); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ notification_channels: 'none' }); + }); + + it('FE-MOB-ANOTIF-008: a failed channel save rolls the toggle back and reports the error', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Webhook' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('switch', { name: 'Webhook' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Email (SMTP)' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-ANOTIF-009: editing an SMTP field and saving sends only the credential keys', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const setTripRemindersEnabled = vi.fn(); + const user = userEvent.setup(); + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({ trip_reminders_enabled: true })), + ); + render( + , + ); + + // smtp_user and smtp_from share a placeholder — the first one is the user field + await user.type(screen.getAllByPlaceholderText('trek@example.com')[0], 'bot'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ smtp_host: 'mail.test', smtp_user: 'bot' }); + expect(toast.success).toHaveBeenCalledWith('Notification settings saved'); + // The follow-up app-config read syncs the reminder flag back into the store + await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(true)); + }); + + it('FE-MOB-ANOTIF-010: a failed SMTP save reports the error', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ANOTIF-011: the TLS toggle flips smtp_skip_tls_verify', async () => { + const bodies = captureAppSettings(); + const user = userEvent.setup(); + render(); + + const tls = screen.getByRole('switch', { name: 'Skip TLS certificate check' }); + expect(tls).toHaveAttribute('aria-checked', 'false'); + await user.click(tls); + expect(tls).toHaveAttribute('aria-checked', 'true'); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toMatchObject({ smtp_skip_tls_verify: 'true' }); + + await user.click(tls); + expect(tls).toHaveAttribute('aria-checked', 'false'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + await waitFor(() => expect(bodies).toHaveLength(2)); + expect(bodies[1]).toMatchObject({ smtp_skip_tls_verify: 'false' }); + }); + + it('FE-MOB-ANOTIF-012: the test button is disabled until a host is configured', () => { + render(); + + expect(screen.getByRole('button', { name: 'Send test email' })).toBeDisabled(); + }); + + it('FE-MOB-ANOTIF-013: the SMTP test persists the credentials first and reports success', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ success: true }))); + render(); + + await user.click(screen.getByRole('button', { name: 'Send test email' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Test email sent successfully')); + expect(bodies[0]).toEqual({ smtp_host: 'mail.test', smtp_from: 'trek@test' }); + }); + + it('FE-MOB-ANOTIF-014: an unsuccessful SMTP test surfaces the server error text', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + server.use( + http.post('/api/notifications/test-smtp', () => + HttpResponse.json({ success: false, error: 'auth rejected' }), + ), + ); + render(); + + await user.click(screen.getByRole('button', { name: 'Send test email' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('auth rejected')); + + // Without an error field the generic message is used + server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({ success: false }))); + await user.click(screen.getByRole('button', { name: 'Send test email' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test email failed')); + }); + + it('FE-MOB-ANOTIF-015: a rejected SMTP test falls back to the generic failure text', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + server.use(http.post('/api/notifications/test-smtp', () => HttpResponse.json({}, { status: 500 }))); + render(); + + await user.click(screen.getByRole('button', { name: 'Send test email' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test email failed')); + }); + + it('FE-MOB-ANOTIF-016: trip reminders default to on and can be switched off', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const setTripRemindersEnabled = vi.fn(); + const user = userEvent.setup(); + server.use( + http.get('/api/auth/app-config', () => HttpResponse.json({ trip_reminders_enabled: false })), + ); + render(); + + const toggle = screen.getByRole('switch', { name: 'Trip Reminders' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await user.click(toggle); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Trip reminders disabled')); + expect(bodies[0]).toEqual({ notify_trip_reminder: 'false' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + await waitFor(() => expect(setTripRemindersEnabled).toHaveBeenCalledWith(false)); + }); + + it('FE-MOB-ANOTIF-017: switching trip reminders back on reports the enabled toast', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Trip Reminders' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Trip reminders enabled')); + expect(bodies[0]).toEqual({ notify_trip_reminder: 'true' }); + }); + + it('FE-MOB-ANOTIF-018: a failed trip reminder save rolls the toggle back', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('switch', { name: 'Trip Reminders' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('switch', { name: 'Trip Reminders' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-ANOTIF-019: the admin webhook URL is saved and tested', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + let testedUrl: string | undefined; + server.use( + http.post('/api/notifications/test-webhook', async ({ request }) => { + testedUrl = ((await request.json()) as { url?: string }).url; + return HttpResponse.json({ success: true }); + }), + ); + render(); + + const input = screen.getByPlaceholderText('https://discord.com/api/webhooks/...'); + await user.type(input, 'https://hooks.test/abc'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin webhook URL saved')); + expect(bodies[0]).toEqual({ admin_webhook_url: 'https://hooks.test/abc' }); + + await user.click(screen.getByRole('button', { name: 'Send test webhook' })); + await waitFor(() => expect(testedUrl).toBe('https://hooks.test/abc')); + expect(toast.success).toHaveBeenCalledWith('Test webhook sent successfully'); + }); + + it('FE-MOB-ANOTIF-020: a masked admin webhook is tested without resending the URL', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + let payload: { url?: string } | undefined; + server.use( + http.post('/api/notifications/test-webhook', async ({ request }) => { + payload = (await request.json()) as { url?: string }; + return HttpResponse.json({ success: false, error: 'endpoint gone' }); + }), + ); + render(); + + // The masked value is never echoed into the input, only into the placeholder + const maskedInput = screen + .getAllByPlaceholderText(MASK) + .find((el) => (el as HTMLInputElement).type === 'text')!; + expect(maskedInput).toHaveValue(''); + + await user.click(screen.getByRole('button', { name: 'Send test webhook' })); + await waitFor(() => expect(payload).toBeDefined()); + // The mask is not a URL, so nothing is re-saved before the probe + expect(payload).toEqual({}); + expect(bodies).toHaveLength(0); + expect(toast.error).toHaveBeenCalledWith('endpoint gone'); + + server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false }))); + await user.click(screen.getByRole('button', { name: 'Send test webhook' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test webhook failed')); + }); + + it('FE-MOB-ANOTIF-021: admin webhook errors are reported for save and test alike', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + failAppSettings(); + server.use( + http.post('/api/notifications/test-webhook', () => HttpResponse.json({}, { status: 500 })), + ); + render(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + + await user.click(screen.getByRole('button', { name: 'Send test webhook' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test webhook failed')); + }); + + it('FE-MOB-ANOTIF-022: the admin webhook test button is disabled without a URL', () => { + render(); + + expect(screen.getByRole('button', { name: 'Send test webhook' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Send test ntfy' })).toBeDisabled(); + }); + + it('FE-MOB-ANOTIF-023: saving admin ntfy sends server, topic and a freshly typed token', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('https://ntfy.sh'), 'https://ntfy.test'); + await user.type(screen.getByPlaceholderText('trek-admin-alerts'), 'ops'); + await user.type(ntfyTokenInput(), 'tk_1'); + await user.click(screen.getAllByRole('button', { name: 'Save' })[2]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin ntfy settings saved')); + expect(bodies[0]).toEqual({ + admin_ntfy_server: 'https://ntfy.test', + admin_ntfy_topic: 'ops', + admin_ntfy_token: 'tk_1', + }); + }); + + it('FE-MOB-ANOTIF-024: a masked ntfy token is omitted from the save payload', async () => { + const bodies = captureAppSettings(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[2]); + + await waitFor(() => expect(bodies).toHaveLength(1)); + expect(bodies[0]).toEqual({ admin_ntfy_server: '', admin_ntfy_topic: 'ops' }); + }); + + it('FE-MOB-ANOTIF-025: a failed ntfy save reports the error', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[2]); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + }); + + it('FE-MOB-ANOTIF-026: the ntfy test sends topic, server and the unmasked token', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + let payload: Record | undefined; + server.use( + http.post('/api/notifications/test-ntfy', async ({ request }) => { + payload = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + render( + , + ); + + await user.click(screen.getByRole('button', { name: 'Send test ntfy' })); + + await waitFor(() => expect(payload).toBeDefined()); + expect(payload).toEqual({ topic: 'ops', server: 'https://ntfy.test', token: 'tk_1' }); + expect(toast.success).toHaveBeenCalledWith('Test ntfy sent successfully'); + }); + + it('FE-MOB-ANOTIF-027: a masked token is sent as null and a failed test is reported', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + let payload: Record | undefined; + server.use( + http.post('/api/notifications/test-ntfy', async ({ request }) => { + payload = (await request.json()) as Record; + return HttpResponse.json({ success: false, error: 'topic refused' }); + }), + ); + render(); + + await user.click(screen.getByRole('button', { name: 'Send test ntfy' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('topic refused')); + expect(payload).toEqual({ topic: 'ops', server: null, token: null }); + + server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false }))); + await user.click(screen.getByRole('button', { name: 'Send test ntfy' })); + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test ntfy failed')); + }); + + it('FE-MOB-ANOTIF-028: a rejected ntfy test falls back to the generic failure text', async () => { + const toast = buildToast(); + const user = userEvent.setup(); + server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({}, { status: 500 }))); + render(); + + await user.click(screen.getByRole('button', { name: 'Send test ntfy' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Test ntfy failed')); + }); + + it('FE-MOB-ANOTIF-029: the Clear button only exists for a stored token and wipes it', async () => { + const bodies = captureAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Admin access token cleared')); + expect(bodies[0]).toEqual({ admin_ntfy_token: '' }); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()); + }); + + it('FE-MOB-ANOTIF-030: a failed token clear keeps the button and reports the error', async () => { + failAppSettings(); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('FE-MOB-ANOTIF-031: a failing app-config refresh does not break the SMTP save', async () => { + const toast = buildToast(); + const setTripRemindersEnabled = vi.fn(); + const user = userEvent.setup(); + captureAppSettings(); + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({}, { status: 500 }))); + render( + , + ); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Notification settings saved')); + expect(setTripRemindersEnabled).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx b/client/tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx new file mode 100644 index 000000000..ecc93d99f --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminNotifyMatrix.test.tsx @@ -0,0 +1,204 @@ +// FE-MOB-AMATRIX-001 to FE-MOB-AMATRIX-011 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useTranslation } from '../../../../src/i18n'; +import type { useToast } from '../../../../src/components/shared/Toast'; +import MAdminNotifyMatrix from '../../../../src/mobile/screens/admin/MAdminNotifyMatrix'; + +const MATRIX = { + event_types: ['version_available', 'trip_reminder'], + channels: [ + { id: 'inapp', active: true }, + { id: 'email', active: true }, + { id: 'webhook', active: false }, + { id: 'ntfy', active: true }, + ], + implemented_combos: { + version_available: ['inapp', 'email'], + trip_reminder: ['inapp'], + }, + preferences: { + version_available: { inapp: true, email: false }, + }, +}; + +function buildToast() { + return { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }; +} + +function Harness({ toast }: { toast: ReturnType }) { + const { t } = useTranslation(); + return ; +} + +function matrixRespondsWith(payload: unknown) { + server.use(http.get('/api/admin/notification-preferences', () => HttpResponse.json(payload))); +} + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminNotifyMatrix', () => { + it('FE-MOB-AMATRIX-001: shows the loading card until the matrix arrives', async () => { + server.use( + http.get('/api/admin/notification-preferences', async () => { + await new Promise(() => {}); + return HttpResponse.json(MATRIX); + }), + ); + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-002: stays on the loading card when the request fails', async () => { + server.use( + http.get('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })), + ); + render(); + + await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-003: shows the empty hint when the server reports no event types', async () => { + matrixRespondsWith({ event_types: [], implemented_combos: {}, preferences: {} }); + render(); + + expect( + await screen.findByText( + 'No notification channels are configured. Ask an admin to set up email or webhook notifications.', + ), + ).toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-004: renders only channels that are active and implemented somewhere', async () => { + matrixRespondsWith(MATRIX); + render(); + + await screen.findByText('New version available'); + expect(screen.getByText('In-App')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + // webhook is inactive, ntfy is active but not implemented for any event + expect(screen.queryByText('Webhook')).not.toBeInTheDocument(); + expect(screen.queryByText('Ntfy')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-005: falls back to the raw event id when no label key exists', async () => { + matrixRespondsWith(MATRIX); + render(); + + expect(await screen.findByText('trip_reminder')).toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-006: reflects stored preferences and defaults missing ones to on', async () => { + matrixRespondsWith(MATRIX); + render(); + + const versionInapp = await screen.findByRole('switch', { name: 'version_available inapp' }); + expect(versionInapp).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + // trip_reminder has no stored preference at all + expect(screen.getByRole('switch', { name: 'trip_reminder inapp' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-AMATRIX-007: renders a dash for combos the backend does not implement', async () => { + matrixRespondsWith(MATRIX); + render(); + + await screen.findByText('trip_reminder'); + expect(screen.queryByRole('switch', { name: 'trip_reminder email' })).not.toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('FE-MOB-AMATRIX-008: toggling a cell persists the whole preference map', async () => { + matrixRespondsWith(MATRIX); + let body: Record> | undefined; + server.use( + http.put('/api/admin/notification-preferences', async ({ request }) => { + body = (await request.json()) as Record>; + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('switch', { name: 'version_available email' })); + + await waitFor(() => expect(body).toBeDefined()); + expect(body).toEqual({ version_available: { inapp: true, email: true } }); + expect(screen.getByRole('switch', { name: 'version_available email' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('FE-MOB-AMATRIX-009: a failed save reverts the toggle and reports the error', async () => { + matrixRespondsWith(MATRIX); + server.use( + http.put('/api/admin/notification-preferences', () => HttpResponse.json({}, { status: 500 })), + ); + const toast = buildToast(); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('switch', { name: 'version_available inapp' })); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('switch', { name: 'version_available inapp' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('FE-MOB-AMATRIX-010: toggling an event without stored preferences seeds it from the default', async () => { + matrixRespondsWith(MATRIX); + let body: Record> | undefined; + server.use( + http.put('/api/admin/notification-preferences', async ({ request }) => { + body = (await request.json()) as Record>; + return HttpResponse.json({ success: true }); + }), + ); + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('switch', { name: 'trip_reminder inapp' })); + + await waitFor(() => expect(body).toBeDefined()); + expect(body).toEqual({ + version_available: { inapp: true, email: false }, + trip_reminder: { inapp: false }, + }); + }); + + it('FE-MOB-AMATRIX-011: a response without a channel list renders the events but no toggles', async () => { + matrixRespondsWith({ + event_types: ['version_available'], + implemented_combos: { version_available: ['inapp'] }, + preferences: {}, + }); + render(); + + expect(await screen.findByText('New version available')).toBeInTheDocument(); + expect(screen.queryByRole('switch')).not.toBeInTheDocument(); + expect(screen.queryByText('In-App')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminPackingTemplateManager.test.tsx b/client/tests/unit/mobile/admin/MAdminPackingTemplateManager.test.tsx new file mode 100644 index 000000000..e5c665533 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminPackingTemplateManager.test.tsx @@ -0,0 +1,677 @@ +// FE-MOB-APKG-001 to FE-MOB-APKG-028 +import { describe, it, expect, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminPackingTemplateManager from '../../../../src/mobile/screens/admin/MAdminPackingTemplateManager'; + +const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }; +const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }; + +const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }; +const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }; +const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }; + +function list(templates: unknown[]) { + return http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates })); +} + +function detail(id: number, categories: unknown[], items: unknown[]) { + return http.get(`/api/admin/packing-templates/${id}`, () => HttpResponse.json({ categories, items })); +} + +function renderManager() { + return render(<>); +} + +// Template rows nest the name two levels below the row that holds the icon buttons. +function templateRow(name: string): HTMLElement { + return screen.getByText(name).parentElement!.parentElement as HTMLElement; +} + +function categoryHeader(name: string): HTMLElement { + return screen.getByText(name).closest('div') as HTMLElement; +} + +function itemRow(name: string): HTMLElement { + return screen.getByText(name).closest('div') as HTMLElement; +} + +beforeEach(() => { + resetAllStores(); +}); + +describe('MAdminPackingTemplateManager', () => { + it('FE-MOB-APKG-001: shows the spinner while templates load, then the empty state', async () => { + server.use( + http.get('/api/admin/packing-templates', async () => { + await new Promise(r => setTimeout(r, 60)); + return HttpResponse.json({ templates: [] }); + }), + ); + renderManager(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.getByText('Packing Templates')).toBeInTheDocument(); + expect(screen.getByText('Create reusable packing lists for your trips')).toBeInTheDocument(); + + await screen.findByText('No templates created yet'); + expect(document.querySelector('.animate-spin')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APKG-002: renders each template with its category and item counts', async () => { + server.use(list([tmpl1, tmpl2])); + renderManager(); + + await screen.findByText('Beach Trip'); + expect(screen.getByText('City Break')).toBeInTheDocument(); + expect(screen.getByText('2 categories · 5 items')).toBeInTheDocument(); + expect(screen.getByText('1 categories · 3 items')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-003: a failing list request surfaces the load error toast', async () => { + server.use(http.get('/api/admin/packing-templates', () => HttpResponse.error())); + renderManager(); + + await screen.findByText('Failed to load templates'); + expect(screen.getByText('No templates created yet')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-004: creating a template prepends it, expands it and toasts', async () => { + const user = userEvent.setup(); + let posted: unknown = null; + server.use( + list([tmpl1]), + http.post('/api/admin/packing-templates', async ({ request }) => { + posted = await request.json(); + return HttpResponse.json({ template: { id: 99, name: 'Winter Trip' } }); + }), + detail(99, [], []), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(screen.getByRole('button', { name: 'New Template' })); + await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' Winter Trip {Enter}'); + + await waitFor(() => expect(posted).toEqual({ name: 'Winter Trip' })); + await screen.findByText('Template created'); + expect(screen.getByText('0 categories · 0 items')).toBeInTheDocument(); + // The new template is expanded right away, so its category editor is visible. + expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APKG-005: saving a blank template name is a no-op', async () => { + const user = userEvent.setup(); + let postCalls = 0; + server.use( + http.post('/api/admin/packing-templates', () => { + postCalls += 1; + return HttpResponse.json({ template: { id: 5, name: '' } }); + }), + ); + renderManager(); + await screen.findByText('No templates created yet'); + + await user.click(screen.getByRole('button', { name: 'New Template' })); + await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), ' '); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(postCalls).toBe(0); + expect(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-006: a failing create shows the create error toast', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/admin/packing-templates', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + renderManager(); + await screen.findByText('No templates created yet'); + + await user.click(screen.getByRole('button', { name: 'New Template' })); + await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Boom{Enter}'); + + await screen.findByText('Failed to create template'); + }); + + it('FE-MOB-APKG-007: Escape and the cancel button both close the create field', async () => { + const user = userEvent.setup(); + renderManager(); + await screen.findByText('No templates created yet'); + + await user.click(screen.getByRole('button', { name: 'New Template' })); + await user.type(screen.getByPlaceholderText('Template name (e.g. Beach Holiday)'), 'Draft{Escape}'); + await waitFor(() => + expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(), + ); + + await user.click(screen.getByRole('button', { name: 'New Template' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => + expect(screen.queryByPlaceholderText('Template name (e.g. Beach Holiday)')).not.toBeInTheDocument(), + ); + }); + + it('FE-MOB-APKG-008: expanding loads categories and items, collapsing hides them again', async () => { + const user = userEvent.setup(); + server.use(list([tmpl1]), detail(1, [cat1], [item1, item2])); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(screen.getByRole('button', { name: 'Expand' })); + await screen.findByText('Clothing'); + expect(screen.getByText('T-shirt')).toBeInTheDocument(); + expect(screen.getByText('Shorts')).toBeInTheDocument(); + // The category header counts its items. + expect(within(categoryHeader('Clothing')).getByText('2')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Collapse' })); + await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument()); + expect(screen.queryByText('T-shirt')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APKG-009: a failing detail request toasts and leaves the template empty', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + http.get('/api/admin/packing-templates/1', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(screen.getByText('Beach Trip')); + + await screen.findByText('Failed to load templates'); + expect(screen.getByRole('button', { name: 'Add category' })).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-010: deleting a template removes it and collapses the expanded panel', async () => { + const user = userEvent.setup(); + let deleted = false; + server.use( + list([tmpl1, tmpl2]), + detail(1, [cat1], [item1]), + http.delete('/api/admin/packing-templates/1', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(deleted).toBe(true)); + await waitFor(() => expect(screen.queryByText('Beach Trip')).not.toBeInTheDocument()); + expect(screen.queryByText('Clothing')).not.toBeInTheDocument(); + expect(screen.getByText('City Break')).toBeInTheDocument(); + await screen.findByText('Template deleted'); + }); + + it('FE-MOB-APKG-011: a failing template delete keeps the row and toasts', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + http.delete('/api/admin/packing-templates/1', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Delete' })); + + await screen.findByText('Failed to delete template'); + expect(screen.getByText('Beach Trip')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-012: renaming a template persists the trimmed name', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + list([tmpl1]), + http.put('/api/admin/packing-templates/1', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('Beach Trip'); + await user.clear(input); + await user.type(input, 'Summer Packing{Enter}'); + + await waitFor(() => expect(body).toEqual({ name: 'Summer Packing' })); + await screen.findByText('Summer Packing'); + }); + + it('FE-MOB-APKG-013: blurring an emptied template name cancels the rename', async () => { + const user = userEvent.setup(); + let putCalls = 0; + server.use( + list([tmpl1]), + http.put('/api/admin/packing-templates/1', () => { + putCalls += 1; + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('Beach Trip'); + await user.clear(input); + fireEvent.blur(input); + + await waitFor(() => expect(screen.getByText('Beach Trip')).toBeInTheDocument()); + expect(putCalls).toBe(0); + }); + + it('FE-MOB-APKG-014: Escape cancels the rename and a failing rename toasts', async () => { + const user = userEvent.setup(); + let putCalls = 0; + server.use( + list([tmpl1]), + http.put('/api/admin/packing-templates/1', () => { + putCalls += 1; + return HttpResponse.error(); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('Beach Trip'), '{Escape}'); + await waitFor(() => expect(screen.queryByDisplayValue('Beach Trip')).not.toBeInTheDocument()); + expect(putCalls).toBe(0); + + await user.click(within(templateRow('Beach Trip')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('Beach Trip'), ' 2{Enter}'); + await screen.findByText('Failed to save'); + expect(putCalls).toBe(1); + }); + + it('FE-MOB-APKG-015: adding a category appends it to the expanded template', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + list([tmpl1]), + detail(1, [], []), + http.post('/api/admin/packing-templates/1/categories', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + + await user.click(await screen.findByRole('button', { name: 'Add category' })); + await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}'); + + await waitFor(() => expect(body).toEqual({ name: 'Electronics' })); + await screen.findByText('Electronics'); + expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APKG-016: a blank category name is ignored and Escape closes the field', async () => { + const user = userEvent.setup(); + let postCalls = 0; + server.use( + list([tmpl1]), + detail(1, [], []), + http.post('/api/admin/packing-templates/1/categories', () => { + postCalls += 1; + return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'x', sort_order: 1 } }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + + await user.click(await screen.findByRole('button', { name: 'Add category' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(postCalls).toBe(0); + + await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Draft{Escape}'); + await waitFor(() => + expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(), + ); + + // Reopening starts from an empty field and the cancel button closes it too. + await user.click(screen.getByRole('button', { name: 'Add category' })); + expect(screen.getByPlaceholderText('Category name (e.g. Clothing)')).toHaveValue(''); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => + expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument(), + ); + expect(postCalls).toBe(0); + }); + + it('FE-MOB-APKG-017: a failing category create toasts the save error', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [], []), + http.post('/api/admin/packing-templates/1/categories', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + + await user.click(await screen.findByRole('button', { name: 'Add category' })); + await user.type(screen.getByPlaceholderText('Category name (e.g. Clothing)'), 'Electronics{Enter}'); + + await screen.findByText('Failed to save'); + }); + + it('FE-MOB-APKG-018: renaming a category updates its header', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.put('/api/admin/packing-templates/1/categories/10', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('Clothing'); + await user.clear(input); + await user.type(input, 'Shoes{Enter}'); + + await waitFor(() => expect(body).toEqual({ name: 'Shoes' })); + await screen.findByText('Shoes'); + }); + + it('FE-MOB-APKG-019: Escape and an emptied field both cancel the category rename', async () => { + const user = userEvent.setup(); + let putCalls = 0; + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.put('/api/admin/packing-templates/1/categories/10', () => { + putCalls += 1; + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('Clothing'), '{Escape}'); + await waitFor(() => expect(screen.queryByDisplayValue('Clothing')).not.toBeInTheDocument()); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('Clothing'); + await user.clear(input); + fireEvent.blur(input); + + await waitFor(() => expect(screen.getByText('Clothing')).toBeInTheDocument()); + expect(putCalls).toBe(0); + }); + + it('FE-MOB-APKG-029: a failing category rename toasts and keeps the old name', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.put('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('Clothing'), ' XL{Enter}'); + + await screen.findByText('Failed to save'); + expect(screen.getByDisplayValue('Clothing XL')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-020: deleting a category drops its items too', async () => { + const user = userEvent.setup(); + let deleted = false; + server.use( + list([tmpl1]), + detail(1, [cat1], [item1, item2]), + http.delete('/api/admin/packing-templates/1/categories/10', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(deleted).toBe(true)); + await waitFor(() => expect(screen.queryByText('Clothing')).not.toBeInTheDocument()); + expect(screen.queryByText('T-shirt')).not.toBeInTheDocument(); + expect(screen.queryByText('Shorts')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APKG-021: a failing category delete toasts and keeps the category', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [cat1], [item1]), + http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Delete' })); + + await screen.findByText('Failed to delete template'); + expect(screen.getByText('Clothing')).toBeInTheDocument(); + }); + + it('FE-MOB-APKG-022: adding an item posts to the category and clears the field', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.post('/api/admin/packing-templates/1/categories/10/items', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' })); + await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}'); + + await waitFor(() => expect(body).toEqual({ name: 'Sandals' })); + await screen.findByText('Sandals'); + expect(screen.getByPlaceholderText('Item name')).toHaveValue(''); + }); + + it('FE-MOB-APKG-023: the add-item confirm button is disabled while the field is empty', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.post('/api/admin/packing-templates/1/categories/10/items', () => + HttpResponse.json({ item: { id: 103, category_id: 10, name: 'Hat', sort_order: 3 } }), + ), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' })); + const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement; + const confirm = within(addRow).getByRole('button', { name: 'Item name' }); + expect(confirm).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('Item name'), 'Hat'); + expect(confirm).toBeEnabled(); + await user.click(confirm); + await screen.findByText('Hat'); + }); + + it('FE-MOB-APKG-024: Escape and cancel close the add-item field without posting', async () => { + const user = userEvent.setup(); + let postCalls = 0; + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.post('/api/admin/packing-templates/1/categories/10/items', () => { + postCalls += 1; + return HttpResponse.json({ item: { id: 104, category_id: 10, name: 'x', sort_order: 4 } }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + const plus = within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' }); + await user.click(plus); + await user.type(screen.getByPlaceholderText('Item name'), 'Draft{Escape}'); + await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()); + + await user.click(plus); + await user.type(screen.getByPlaceholderText('Item name'), 'Draft'); + const addRow = screen.getByPlaceholderText('Item name').closest('div') as HTMLElement; + await user.click(within(addRow).getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()); + + // The plus button toggles the field shut when pressed a second time. + await user.click(plus); + expect(screen.getByPlaceholderText('Item name')).toBeInTheDocument(); + await user.click(plus); + await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()); + expect(postCalls).toBe(0); + }); + + it('FE-MOB-APKG-025: a failing item create toasts the save error', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [cat1], []), + http.post('/api/admin/packing-templates/1/categories/10/items', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('Clothing'); + + await user.click(within(categoryHeader('Clothing')).getByRole('button', { name: 'Item name' })); + await user.type(screen.getByPlaceholderText('Item name'), 'Sandals{Enter}'); + + await screen.findByText('Failed to save'); + }); + + it('FE-MOB-APKG-026: renaming an item persists via the save button', async () => { + const user = userEvent.setup(); + let body: unknown = null; + server.use( + list([tmpl1]), + detail(1, [cat1], [item1]), + http.put('/api/admin/packing-templates/1/items/100', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('T-shirt'); + + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('T-shirt'); + await user.clear(input); + await user.type(input, 'Tank Top'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(body).toEqual({ name: 'Tank Top' })); + await screen.findByText('Tank Top'); + }); + + it('FE-MOB-APKG-027: item rename cancels on Escape, on cancel and on an empty name', async () => { + const user = userEvent.setup(); + let putCalls = 0; + server.use( + list([tmpl1]), + detail(1, [cat1], [item1]), + http.put('/api/admin/packing-templates/1/items/100', () => { + putCalls += 1; + return HttpResponse.error(); + }), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('T-shirt'); + + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('T-shirt'), '{Escape}'); + await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument()); + + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByDisplayValue('T-shirt')).not.toBeInTheDocument()); + + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' })); + const input = screen.getByDisplayValue('T-shirt'); + await user.clear(input); + await user.type(input, '{Enter}'); + await waitFor(() => expect(screen.getByText('T-shirt')).toBeInTheDocument()); + expect(putCalls).toBe(0); + + // A rejected rename keeps the old name and toasts. + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Edit' })); + await user.type(screen.getByDisplayValue('T-shirt'), ' XL{Enter}'); + await screen.findByText('Failed to save'); + expect(putCalls).toBe(1); + }); + + it('FE-MOB-APKG-028: deleting an item removes only that item, failures toast', async () => { + const user = userEvent.setup(); + server.use( + list([tmpl1]), + detail(1, [cat1], [item1, item2]), + http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true })), + http.delete('/api/admin/packing-templates/1/items/101', () => HttpResponse.error()), + ); + renderManager(); + await screen.findByText('Beach Trip'); + await user.click(screen.getByText('Beach Trip')); + await screen.findByText('T-shirt'); + + await user.click(within(itemRow('T-shirt')).getByRole('button', { name: 'Delete' })); + await waitFor(() => expect(screen.queryByText('T-shirt')).not.toBeInTheDocument()); + expect(screen.getByText('Shorts')).toBeInTheDocument(); + + await user.click(within(itemRow('Shorts')).getByRole('button', { name: 'Delete' })); + await screen.findByText('Failed to delete template'); + expect(screen.getByText('Shorts')).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminPermissionsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPermissionsPanel.test.tsx new file mode 100644 index 000000000..0a1e2dc2b --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminPermissionsPanel.test.tsx @@ -0,0 +1,220 @@ +// FE-MOB-APERM-001 to FE-MOB-APERM-011 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { usePermissionsStore } from '../../../../src/store/permissionsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAdminPermissionsPanel from '../../../../src/mobile/screens/admin/MAdminPermissionsPanel'; + +const ALLOWED = ['admin', 'trip_owner', 'trip_member', 'everybody']; + +function buildPermission(key: string, level = 'trip_member', defaultLevel = 'trip_member') { + return { key, level, defaultLevel, allowedLevels: [...ALLOWED] }; +} + +const ALL_KEYS = [ + 'trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload', + 'member_manage', + 'file_upload', 'file_edit', 'file_delete', + 'place_edit', 'day_edit', 'reservation_edit', + 'budget_edit', 'packing_edit', 'collab_edit', 'share_manage', +]; + +const SAMPLE_PERMISSIONS = ALL_KEYS.map((k) => buildPermission(k)); + +function renderPanel() { + return render( + <> + + + , + ); +} + +beforeEach(() => { + resetAllStores(); + server.use( + http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })), + ); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminPermissionsPanel', () => { + it('FE-MOB-APERM-001: renders only the spinner while loading', () => { + server.use( + http.get('/api/admin/permissions', async () => { + await new Promise(() => {}); + return HttpResponse.json({ permissions: [] }); + }), + ); + renderPanel(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Permission Settings')).not.toBeInTheDocument(); + }); + + it('FE-MOB-APERM-002: renders every category and its actions after load', async () => { + renderPanel(); + + await screen.findByText('Permission Settings'); + expect(screen.getByText('Control who can perform actions across the application')).toBeInTheDocument(); + expect(screen.getByText('Trip Management')).toBeInTheDocument(); + expect(screen.getByText('Member Management')).toBeInTheDocument(); + expect(screen.getByText('Files')).toBeInTheDocument(); + expect(screen.getByText('Content & Schedule')).toBeInTheDocument(); + expect(screen.getByText('Budget, Packing & Collaboration')).toBeInTheDocument(); + expect(screen.getByText('Create trips')).toBeInTheDocument(); + expect(screen.getByText('Who can create new trips')).toBeInTheDocument(); + expect(screen.getAllByRole('combobox')).toHaveLength(ALL_KEYS.length); + }); + + it('FE-MOB-APERM-003: a select reflects the stored level and offers every allowed level', async () => { + renderPanel(); + + await screen.findByText('Permission Settings'); + const select = screen.getByRole('combobox', { name: 'Create trips' }) as HTMLSelectElement; + expect(select.value).toBe('trip_member'); + expect(Array.from(select.options).map((o) => o.textContent)).toEqual([ + 'Admin only', 'Trip owner', 'Trip members', 'Everyone', + ]); + }); + + it('FE-MOB-APERM-004: keys missing from the payload are skipped', async () => { + server.use( + http.get('/api/admin/permissions', () => + HttpResponse.json({ permissions: [buildPermission('trip_create')] }), + ), + ); + renderPanel(); + + await screen.findByText('Permission Settings'); + expect(screen.getByText('Create trips')).toBeInTheDocument(); + expect(screen.queryByText('Delete trips')).not.toBeInTheDocument(); + expect(screen.getAllByRole('combobox')).toHaveLength(1); + }); + + it('FE-MOB-APERM-005: the customized badge shows only where level differs from the default', async () => { + server.use( + http.get('/api/admin/permissions', () => + HttpResponse.json({ + permissions: [ + buildPermission('trip_create', 'admin', 'trip_member'), + buildPermission('trip_edit', 'trip_member', 'trip_member'), + ], + }), + ), + ); + renderPanel(); + + await screen.findByText('Permission Settings'); + expect(screen.getAllByText('customized')).toHaveLength(1); + }); + + it('FE-MOB-APERM-006: Save stays disabled until a select changes', async () => { + const user = userEvent.setup(); + renderPanel(); + + await screen.findByText('Permission Settings'); + const save = screen.getByRole('button', { name: /Save/ }); + expect(save).toBeDisabled(); + + await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin'); + + expect(save).not.toBeDisabled(); + expect(screen.getAllByText('customized')).toHaveLength(1); + }); + + it('FE-MOB-APERM-007: Reset puts every value back to its default and marks the form dirty', async () => { + const user = userEvent.setup(); + server.use( + http.get('/api/admin/permissions', () => + HttpResponse.json({ + permissions: [ + buildPermission('trip_create', 'admin', 'trip_member'), + buildPermission('trip_edit', 'everybody', 'trip_owner'), + ], + }), + ), + ); + renderPanel(); + + await screen.findByText('Permission Settings'); + expect(screen.getAllByText('customized')).toHaveLength(2); + + await user.click(screen.getByRole('button', { name: 'Reset to defaults' })); + + await waitFor(() => expect(screen.queryByText('customized')).not.toBeInTheDocument()); + expect((screen.getByRole('combobox', { name: 'Create trips' }) as HTMLSelectElement).value).toBe('trip_member'); + expect((screen.getByRole('combobox', { name: 'Edit trip details' }) as HTMLSelectElement).value).toBe('trip_owner'); + expect(screen.getByRole('button', { name: /Save/ })).not.toBeDisabled(); + }); + + it('FE-MOB-APERM-008: saving PUTs the current values and pushes them into the store', async () => { + const user = userEvent.setup(); + let sent: Record = {}; + server.use( + http.put('/api/admin/permissions', async ({ request }) => { + sent = (await request.json()) as Record; + return HttpResponse.json({ permissions: { trip_create: 'admin' } }); + }), + ); + renderPanel(); + + await screen.findByText('Permission Settings'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin'); + await user.click(screen.getByRole('button', { name: /Save/ })); + + await screen.findByText('Permission settings saved'); + expect((sent.permissions as Record).trip_create).toBe('admin'); + expect(usePermissionsStore.getState().permissions.trip_create).toBe('admin'); + await waitFor(() => expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled()); + }); + + it('FE-MOB-APERM-009: a response without permissions leaves the store untouched', async () => { + const user = userEvent.setup(); + const before = usePermissionsStore.getState().permissions; + server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ ok: true }))); + renderPanel(); + + await screen.findByText('Permission Settings'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin'); + await user.click(screen.getByRole('button', { name: /Save/ })); + + await screen.findByText('Permission settings saved'); + expect(usePermissionsStore.getState().permissions).toBe(before); + }); + + it('FE-MOB-APERM-010: a failing save toasts and keeps the form dirty', async () => { + const user = userEvent.setup(); + server.use( + http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderPanel(); + + await screen.findByText('Permission Settings'); + await user.selectOptions(screen.getByRole('combobox', { name: 'Create trips' }), 'admin'); + const save = screen.getByRole('button', { name: /Save/ }); + await user.click(save); + + await screen.findByText('Error'); + expect(save).not.toBeDisabled(); + }); + + it('FE-MOB-APERM-011: a failing load toasts and renders no categories', async () => { + server.use( + http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderPanel(); + + await screen.findByText('Error'); + expect(screen.getByText('Permission Settings')).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx new file mode 100644 index 000000000..cef494f82 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx @@ -0,0 +1,1369 @@ +// FE-MOB-PLUGP-001 onwards +import { http, HttpResponse } from 'msw'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; +import { server } from '../../../helpers/msw/server'; +import MAdminPluginsPanel from '../../../../src/mobile/screens/admin/MAdminPluginsPanel'; + +type Row = Record; + +function plugin(over: Row = {}): Row { + return { + id: 'trek-gotify', + name: 'Gotify', + description: 'Push notifications', + type: 'integration', + icon: 'Bell', + version: '1.0.0', + status: 'active', + enabled: 1, + last_error: null, + reviewed_at: null, + source_repo: null, + permissions: JSON.stringify(['hook:notification-channel', 'http:outbound:gotify.net']), + capabilities: '{}', + operatorEgress: false, + egressHostCount: 0, + dependencyStatus: 'ok', + dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [] }, + ...over, + }; +} + +function registryEntry(over: Row = {}): Row { + return { + id: 'trek-gotify', + name: 'Gotify', + author: 'Acme', + description: 'Push notifications for TREK', + repo: 'acme/gotify', + type: 'integration', + latest: '2.0.0', + minTrekVersion: null, + reviewedAt: null, + screenshotUrl: null, + downloadCount: null, + signed: true, + authorPublicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321', + ...over, + }; +} + +/** The two calls every mount makes: the installed list, then (if non-empty) the registry. */ +function mockPanel(plugins: Row[], registry: Row[] = [], extra: Row = {}) { + server.use( + http.get('*/api/admin/plugins', () => + HttpResponse.json({ enabled: true, devLink: false, plugins, ...extra })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json(registry)), + ); +} + +let toasts: Array<{ message: string; type: string }> = []; + +beforeEach(() => { + resetAllStores(); + toasts = []; + // useToast is a thin wrapper around this global; the ToastContainer is never mounted here. + window.__addToast = ((message: string, type?: string) => { + toasts.push({ message, type: type ?? 'info' }); + return toasts.length; + }) as Window['__addToast']; + // refresh() also nudges the app-wide active-plugin store. + server.use(http.get('*/api/plugins', () => HttpResponse.json({ plugins: [] }))); +}); + +function toastMessages() { + return toasts.map(t => t.message); +} + +describe('MAdminPluginsPanel — load states', () => { + it('FE-MOB-PLUGP-001: shows the load error when the plugin list cannot be fetched', async () => { + server.use(http.get('*/api/admin/plugins', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + render(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + expect(await screen.findByText('Could not load plugins.')).toBeInTheDocument(); + // No toolbar behind an error — there is nothing to filter. + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-002: explains that the runtime is off and hides the toolbar', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })), + ); + render(); + + expect(await screen.findByText('Plugins are disabled')).toBeInTheDocument(); + expect(screen.queryByText('Runtime on')).not.toBeInTheDocument(); + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-003: an empty install list offers the Discover jump', async () => { + mockPanel([], [registryEntry()]); + render(); + + expect(await screen.findByText('No plugins installed yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Discover/ })); + + // The registry only loads once the tab is opened for an empty instance. + expect(await screen.findByText('Acme')).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — the installed row', () => { + it('FE-MOB-PLUGP-004: renders name, version, runtime badge and the capability chips', async () => { + mockPanel([plugin()]); + render(); + + expect(await screen.findByText('Gotify')).toBeInTheDocument(); + expect(screen.getByText('Runtime on')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('Push notifications')).toBeInTheDocument(); + // Derived from the permission list, not from a separate field. + expect(screen.getByText('Notification channel')).toBeInTheDocument(); + expect(screen.getByText('gotify.net')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-005: covers the whole capability vocabulary, including a replaced planner tab', async () => { + mockPanel([plugin({ + permissions: JSON.stringify([ + 'db:read:trips', 'db:read:users', 'db:write:costs', 'db:read:packing', 'db:read:files', + 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:meta', + 'ws:broadcast:trip', 'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', + 'hook:trip-warning-provider', 'hook:map-layer-provider', 'hook:route-provider', + 'hook:day-schedule-provider', 'geolocation:read', 'events:subscribe', + ]), + capabilities: JSON.stringify({ widget: { slot: 'hero' }, tripPage: { replaces: ['costs'] } }), + })]); + render(); + + await screen.findByText('Gotify'); + for (const label of [ + 'Reads your trips', 'Reads basic profiles', 'Adds costs', 'Reads packing lists', 'Reads trip files', + 'Edits places', 'Edits days', 'Edits itinerary', 'Edits trips', 'Adds metadata', + 'Boarding-pass widget', 'Replaces planner tabs', 'Real-time updates', 'Provides photos', + 'Provides calendar events', 'Enriches places', 'Flags issues', 'Draws on the map', + 'Offers routing', 'Adds plan times', 'Reads your position', 'Reacts to activity', + ]) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + }); + + it('FE-MOB-PLUGP-006: read-only costs downgrade the costs chip and a broken permissions blob degrades to none', async () => { + mockPanel([ + plugin({ id: 'reader', name: 'Reader', permissions: JSON.stringify(['db:read:costs']) }), + plugin({ id: 'broken', name: 'Broken', permissions: 'not-json', capabilities: 'not-json' }), + ]); + render(); + + expect(await screen.findByText('Reads your costs')).toBeInTheDocument(); + expect(screen.queryByText('Adds costs')).not.toBeInTheDocument(); + // The unparseable row still renders — a bad blob must not take the panel down. + expect(screen.getByText('Broken')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-007: an errored plugin shows its last error instead of the chips', async () => { + mockPanel([plugin({ status: 'error', last_error: 'child exited with code 1' })]); + render(); + + expect(await screen.findByText('child exited with code 1')).toBeInTheDocument(); + expect(screen.queryByText('Notification channel')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-008: dependency chips name the blocker — addon, plugin and TREK range', async () => { + mockPanel([plugin({ + enabled: 0, + status: 'inactive', + dependencyStatus: 'hostIncompatible', + trekRange: '>=3.2.0 <4.0.0', + hostVersion: '4.0.0', + dependencies: { requiredAddons: ['budget'], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] }, + dependencyIssues: { disabledAddons: ['budget'], missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, + })]); + render(); + + expect(await screen.findByText('Needs TREK >=3.2.0 <4.0.0 — this server runs 4.0.0')).toBeInTheDocument(); + expect(screen.getByText('Requires budget')).toBeInTheDocument(); + expect(screen.getByText('Needs trek-base ^1.0.0')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-009: a plugin that never declared a TREK range says so', async () => { + mockPanel([plugin({ dependencyStatus: 'hostIncompatible', trekRange: null })]); + render(); + + expect(await screen.findByText('Does not say which TREK versions it supports')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-010: the source badge wins over the trust badge', async () => { + mockPanel([ + plugin({ id: 'a', name: 'Registry one', source_repo: 'acme/gotify', signed: true, reviewed_at: '2026-01-01' }), + plugin({ id: 'b', name: 'Registry two', source_repo: 'acme/other', signed: false }), + plugin({ id: 'c', name: 'Uploaded', source_repo: 'local:upload', signed: false }), + plugin({ id: 'd', name: 'Linked', source_repo: 'local:link', signed: false }), + ]); + render(); + + await screen.findByText('Registry one'); + expect(screen.getByText('Signed')).toBeInTheDocument(); + // Exactly one Unsigned — the sideloaded and dev-linked rows say something stronger. + expect(screen.getAllByText('Unsigned')).toHaveLength(1); + expect(screen.getByText('Sideloaded')).toBeInTheDocument(); + expect(screen.getByText('Dev-Link')).toBeInTheDocument(); + expect(screen.getByLabelText('Reviewed')).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — toolbar', () => { + const rows = [ + plugin({ id: 'alpha', name: 'Alpha', description: 'the first', type: 'widget', enabled: 1, status: 'active' }), + plugin({ id: 'beta', name: 'Beta', description: 'the second', type: 'page', enabled: 0, status: 'inactive' }), + plugin({ id: 'gamma', name: 'Gamma', description: 'the third', type: 'integration', status: 'error', last_error: 'nope' }), + ]; + + it('FE-MOB-PLUGP-011: the search box filters on name and description', async () => { + mockPanel(rows); + render(); + await screen.findByText('Alpha'); + + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } }); + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'nothing here' } }); + expect(screen.getByText('No installed plugins match your search.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-012: the type sheet narrows the list to one plugin type', async () => { + mockPanel(rows); + render(); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByTitle('Type: All types')); + fireEvent.click(await screen.findByRole('button', { name: 'Page' })); + + await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument()); + expect(screen.getByText('Beta')).toBeInTheDocument(); + expect(screen.getByTitle('Type: Page')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-013: the status sheet separates on, off and errored plugins', async () => { + mockPanel(rows); + render(); + await screen.findByText('Alpha'); + + fireEvent.click(screen.getByTitle('Status: All')); + fireEvent.click(await screen.findByRole('button', { name: 'Off' })); + await waitFor(() => expect(screen.queryByText('Alpha')).not.toBeInTheDocument()); + expect(screen.getByText('Beta')).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle('Status: Off')); + fireEvent.click(await screen.findByRole('button', { name: 'Error' })); + await waitFor(() => expect(screen.queryByText('Beta')).not.toBeInTheDocument()); + expect(screen.getByText('Gamma')).toBeInTheDocument(); + + fireEvent.click(screen.getByTitle('Status: Error')); + fireEvent.click(await screen.findByRole('button', { name: 'Active' })); + await waitFor(() => expect(screen.queryByText('Gamma')).not.toBeInTheDocument()); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-014: "Updates first" ranks the updatable plugin above an alphabetically earlier one', async () => { + mockPanel( + [ + plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }), + plugin({ id: 'zulu', name: 'Zulu', version: '1.0.0' }), + ], + [registryEntry({ id: 'zulu', name: 'Zulu', latest: '2.0.0' }), registryEntry({ id: 'alpha', name: 'Alpha', latest: '1.0.0' })], + ); + render(); + await screen.findByText('1 updates available for your plugins.'); + + fireEvent.click(screen.getByTitle('Sort: Name')); + fireEvent.click(await screen.findByRole('button', { name: 'Updates first' })); + + await waitFor(() => { + const names = screen.getAllByText(/^(Alpha|Zulu)$/).map(n => n.textContent); + expect(names).toEqual(['Zulu', 'Alpha']); + }); + }); + + it('FE-MOB-PLUGP-015: the "update available" status filter keeps only the plugin with an update', async () => { + mockPanel( + [plugin({ id: 'alpha', name: 'Alpha', version: '1.0.0' }), plugin({ id: 'zulu', name: 'Zulu', version: '9.0.0' })], + [registryEntry({ id: 'alpha', name: 'Alpha', latest: '2.0.0' }), registryEntry({ id: 'zulu', name: 'Zulu', latest: '9.0.0' })], + ); + render(); + await screen.findByText('1 updates available for your plugins.'); + + fireEvent.click(screen.getByTitle('Status: All')); + fireEvent.click(await screen.findByRole('button', { name: 'Update available' })); + + await waitFor(() => expect(screen.queryByText('Zulu')).not.toBeInTheDocument()); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — Discover', () => { + async function openDiscover(entries: Row[]) { + mockPanel([], entries); + const view = render(); + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + return view; + } + + it('FE-MOB-PLUGP-016: a registry card carries author, type, version, downloads and the reviewed ribbon', async () => { + await openDiscover([registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 1234, screenshotUrl: null })]); + + expect(await screen.findByText('Acme')).toBeInTheDocument(); + expect(screen.getByText('Integration')).toBeInTheDocument(); + expect(screen.getByText('v2.0.0')).toBeInTheDocument(); + expect(screen.getByText('1.2k')).toBeInTheDocument(); + expect(screen.getAllByText('Reviewed').length).toBeGreaterThan(0); + }); + + it('FE-MOB-PLUGP-017: installing from a card posts the id and reports success', async () => { + let body: unknown = null; + await openDiscover([registryEntry()]); + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ ok: true }); + })); + + fireEvent.click(await screen.findByRole('button', { name: /^Install$/ })); + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify' })); + await waitFor(() => expect(toastMessages()).toContain('Installed')); + }); + + it('FE-MOB-PLUGP-018: an entry no published version fits gets a dead, explained button', async () => { + await openDiscover([registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })]); + + const btn = await screen.findByRole('button', { name: 'Incompatible' }); + expect(btn).toBeDisabled(); + expect(btn).toHaveAttribute('title', 'Needs TREK >=4.0.0 — this server runs 3.3.0'); + }); + + it('FE-MOB-PLUGP-019: when only an older release fits, that version is offered and installed', async () => { + let body: unknown = null; + await openDiscover([registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' })]); + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ ok: true }); + })); + + fireEvent.click(await screen.findByRole('button', { name: 'Install 1.5.0' })); + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })); + }); + + it('FE-MOB-PLUGP-020: an already-installed plugin cannot be installed twice', async () => { + mockPanel([plugin()], [registryEntry()]); + render(); + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + + const btn = await screen.findByRole('button', { name: 'Installed' }); + expect(btn).toBeDisabled(); + }); + + it('FE-MOB-PLUGP-021: an empty registry and a filtered-to-nothing registry read differently', async () => { + await openDiscover([]); + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-022: a search with no registry hit says so', async () => { + await openDiscover([registryEntry()]); + await screen.findByText('Acme'); + + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzz' } }); + expect(screen.getByText('No plugins in the registry match your search.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-023: an unreachable registry degrades to an empty list, not a crash', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })), + ); + render(); + fireEvent.click(await screen.findByRole('button', { name: /Discover/ })); + + expect(await screen.findByText('No plugins available in the registry yet.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-024: Discover sorts by downloads and by review date', async () => { + await openDiscover([ + registryEntry({ id: 'a', name: 'Aaa', downloadCount: 5, reviewedAt: '2026-01-01T00:00:00Z' }), + registryEntry({ id: 'z', name: 'Zzz', downloadCount: 900, reviewedAt: '2026-06-01T00:00:00Z' }), + ]); + await screen.findByText('Aaa'); + + fireEvent.click(screen.getByTitle('Sort: Name')); + fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' })); + await waitFor(() => { + expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']); + }); + + fireEvent.click(screen.getByTitle('Sort: Most downloads')); + fireEvent.click(await screen.findByRole('button', { name: 'Recently updated' })); + await waitFor(() => { + expect(screen.getAllByText(/^(Aaa|Zzz)$/).map(n => n.textContent)).toEqual(['Zzz', 'Aaa']); + }); + }); + + it('FE-MOB-PLUGP-025: switching back to Installed drops a sort key that tab cannot offer', async () => { + await openDiscover([registryEntry()]); + await screen.findByText('Acme'); + + fireEvent.click(screen.getByTitle('Sort: Name')); + fireEvent.click(await screen.findByRole('button', { name: 'Most downloads' })); + await waitFor(() => expect(screen.getByTitle('Sort: Most downloads')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('tab', { name: /Installed/ })); + await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-026: a broken screenshot falls back to the placeholder tile', async () => { + const { container } = await openDiscover([registryEntry({ screenshotUrl: 'https://example.test/shot.png' })]); + + await screen.findByText('Acme'); + const img = container.querySelector('img') as HTMLImageElement; + expect(img).toHaveAttribute('src', 'https://example.test/shot.png'); + + fireEvent.error(img); + await waitFor(() => expect(container.querySelector('img')).toBeNull()); + }); + + it('FE-MOB-PLUGP-027: the card opens the detail sheet from the keyboard too', async () => { + await openDiscover([registryEntry()]); + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => + HttpResponse.json({ ...registryEntry(), size: null, publishedAt: null, manifest: null }))); + + const card = (await screen.findByText('Acme')).closest('[role="button"]') as HTMLElement; + fireEvent.keyDown(card, { key: 'Enter' }); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — the registry detail sheet', () => { + const manifest = { + permissions: ['db:own', 'db:read:trips', 'http:outbound:gotify.net'], + egress: ['gotify.net'], + operatorEgress: true, + settings: [{ key: 'token', label: 'API token', inputType: 'password', scope: 'instance', required: true }], + license: 'MIT', + icon: 'Bell', + }; + + async function openDetail(detail: Row, entry: Row = registryEntry()) { + mockPanel([], [entry]); + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => HttpResponse.json(detail))); + render(); + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + fireEvent.click(await screen.findByText('Acme')); + return screen.findByRole('dialog'); + } + + it('FE-MOB-PLUGP-028: lists access, outbound hosts, the operator caveat, setup fields and the meta grid', async () => { + const entry = registryEntry({ reviewedAt: '2026-01-05T00:00:00Z', downloadCount: 4200, trek: '>=3.2.0 <4.0.0' }); + await openDetail({ ...entry, size: 4096, publishedAt: null, manifest }, entry); + + expect(await screen.findByText('What it can access')).toBeInTheDocument(); + expect(screen.getByText('Reads your trips')).toBeInTheDocument(); + expect(screen.getByText('Store its own data in an isolated database')).toBeInTheDocument(); + expect(screen.getByText('gotify.net')).toBeInTheDocument(); + expect(screen.getByText('+ hosts you add')).toBeInTheDocument(); + expect(screen.getByText('API token')).toBeInTheDocument(); + expect(screen.getByText('Instance-wide')).toBeInTheDocument(); + expect(screen.getByText('Required')).toBeInTheDocument(); + expect(screen.getByText('4 KB')).toBeInTheDocument(); + expect(screen.getByText('TREK >=3.2.0 <4.0.0')).toBeInTheDocument(); + expect(screen.getByText('4,200')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-029: a plugin that needs nothing says so, and falls back to the min-version line', async () => { + await openDetail( + { ...registryEntry({ minTrekVersion: '3.0.0' }), size: null, publishedAt: null, + manifest: { permissions: [], egress: [], settings: [], license: null, icon: null } }, + registryEntry({ minTrekVersion: '3.0.0' }), + ); + + expect(await screen.findByText('Needs no special access.')).toBeInTheDocument(); + expect(screen.getByText('TREK 3.0.0+')).toBeInTheDocument(); + expect(screen.queryByText('Connects to')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-030: a failed detail fetch is reported inside the sheet', async () => { + mockPanel([], [registryEntry()]); + server.use(http.get('*/api/admin/plugins/registry/trek-gotify', () => + HttpResponse.json({ error: 'nope' }, { status: 500 }))); + render(); + fireEvent.click(await screen.findByRole('button', { name: /Discover/ })); + fireEvent.click(await screen.findByText('Acme')); + + expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-031: the blocked reason is spelled out in the sheet, not just as a tooltip', async () => { + const entry = registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null }); + await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry); + + expect(await screen.findAllByText('Needs TREK >=4.0.0 — this server runs 3.3.0')).toHaveLength(1); + }); + + it('FE-MOB-PLUGP-032: a distinct homepage gets its own link, and the sheet closes again', async () => { + await openDetail( + { ...registryEntry({ homepage: 'https://gotify.example' }), size: null, publishedAt: null, manifest: null }, + registryEntry({ homepage: 'https://gotify.example' }), + ); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByRole('link', { name: /Homepage/ })).toHaveAttribute('href', 'https://gotify.example'); + expect(within(dialog).getByRole('link', { name: /Source repository/ })).toHaveAttribute('href', 'https://github.com/acme/gotify'); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-033: installing straight from the sheet uses the offered version', async () => { + let body: unknown = null; + const entry = registryEntry({ trek: '>=3.4.0', hostVersion: '3.3.0', compatible: false, latestCompatible: '1.5.0' }); + await openDetail({ ...entry, size: null, publishedAt: null, manifest: null }, entry); + server.use(http.post('*/api/admin/plugins/install', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ ok: true }); + })); + + const dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Install 1.5.0' })); + await waitFor(() => expect(body).toEqual({ id: 'trek-gotify', version: '1.5.0' })); + }); +}); + +describe('MAdminPluginsPanel — the row action sheet', () => { + async function openRowMenu(over: Row = {}) { + mockPanel([plugin(over)]); + render(); + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + return screen.findByRole('dialog'); + } + + it('FE-MOB-PLUGP-034: a registry plugin offers every action plus the repository links', async () => { + const dialog = await openRowMenu({ source_repo: 'acme/gotify' }); + + for (const label of ['Restart', 'View error log', 'Allowed hosts', 'Source repository', 'Report an issue', 'Delete']) { + expect(within(dialog).getByText(label)).toBeInTheDocument(); + } + expect(within(dialog).getByRole('link', { name: /Report an issue/ })) + .toHaveAttribute('href', 'https://github.com/acme/gotify/issues'); + }); + + it('FE-MOB-PLUGP-035: a sideloaded, disabled plugin gets neither Restart nor repo links', async () => { + const dialog = await openRowMenu({ source_repo: 'local:upload', enabled: 0, status: 'inactive' }); + + expect(within(dialog).queryByText('Restart')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Source repository')).not.toBeInTheDocument(); + expect(within(dialog).getByText('Delete')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-036: Restart cycles the plugin off and on', async () => { + const calls: string[] = []; + await openRowMenu(); + server.use( + http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { calls.push('deactivate'); return HttpResponse.json({ ok: true }); }), + http.post('*/api/admin/plugins/trek-gotify/activate', () => { calls.push('activate'); return HttpResponse.json({ ok: true }); }), + ); + + fireEvent.click(screen.getByText('Restart')); + await waitFor(() => expect(calls).toEqual(['deactivate', 'activate'])); + expect(toastMessages()).toContain('Plugin restarted'); + }); + + it('FE-MOB-PLUGP-037: the error log sheet lists the recorded lines', async () => { + await openRowMenu(); + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => + HttpResponse.json({ errors: [{ ts: '2026-07-01T10:00:00Z', level: 'error', message: 'ECONNREFUSED' }] }))); + + fireEvent.click(screen.getByText('View error log')); + expect(await screen.findByText('ECONNREFUSED')).toBeInTheDocument(); + expect(screen.getByText('error')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-038: a failing error-log fetch still opens the sheet, empty', async () => { + await openRowMenu(); + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => + HttpResponse.json({ error: 'nope' }, { status: 500 }))); + + fireEvent.click(screen.getByText('View error log')); + expect(await screen.findByText('No errors logged.')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-039: Delete asks first, then uninstalls with its data', async () => { + let body: unknown = null; + await openRowMenu(); + server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ ok: true }); + })); + + fireEvent.click(screen.getByText('Delete')); + expect(await screen.findByText('Uninstall plugin?')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(body).toEqual({ deleteData: true })); + await waitFor(() => expect(toastMessages()).toContain('Plugin uninstalled')); + }); + + it('FE-MOB-PLUGP-040: cancelling the uninstall confirmation calls nothing', async () => { + let called = false; + await openRowMenu(); + server.use(http.post('*/api/admin/plugins/trek-gotify/uninstall', () => { called = true; return HttpResponse.json({ ok: true }); })); + + fireEvent.click(screen.getByText('Delete')); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByText('Uninstall plugin?')).not.toBeInTheDocument()); + expect(called).toBe(false); + }); +}); + +describe('MAdminPluginsPanel — operator-supplied egress hosts', () => { + it('FE-MOB-PLUGP-041: the chip invites a first host and turns into a count once one exists', async () => { + mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]); + const { unmount } = render(); + expect(await screen.findByRole('button', { name: 'Add allowed host' })).toBeInTheDocument(); + unmount(); + + mockPanel([plugin({ operatorEgress: true, egressHostCount: 2 })]); + render(); + expect(await screen.findByRole('button', { name: '2 allowed host(s)' })).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-042: the sheet adds and removes hosts through the API', async () => { + const bodies: unknown[] = []; + mockPanel([plugin({ operatorEgress: true, egressHostCount: 1 })]); + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: ['gotify.mydomain.test'] })), + http.put('*/api/admin/plugins/trek-gotify/egress-hosts', async ({ request }) => { + const body = await request.json() as { hosts: string[] }; + bodies.push(body); + return HttpResponse.json({ hosts: body.hosts }); + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: '1 allowed host(s)' })); + expect(await screen.findByText('gotify.mydomain.test')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: ' ntfy.mydomain.test ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + await waitFor(() => expect(bodies[0]).toEqual({ hosts: ['gotify.mydomain.test', 'ntfy.mydomain.test'] })); + + fireEvent.click((await screen.findAllByRole('button', { name: 'Delete' }))[0]); + await waitFor(() => expect(bodies[1]).toEqual({ hosts: ['ntfy.mydomain.test'] })); + }); + + it('FE-MOB-PLUGP-043: a rejected save surfaces the server message and keeps the sheet open', async () => { + mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]); + server.use( + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })), + http.put('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ error: 'not a hostname' }, { status: 400 })), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' })); + expect(await screen.findByText('No hosts added yet.')).toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText('gotify.example.com'), { target: { value: 'not a host' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + expect(await screen.findByText('not a hostname')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-044: a plugin with fixed manifest hosts says the sheet does not apply', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify' })]); + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: false, hosts: [] }))); + render(); + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + fireEvent.click(screen.getByText('Allowed hosts')); + expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-045: a failing hosts fetch degrades to the unsupported notice', async () => { + mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]); + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ error: 'nope' }, { status: 500 }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' })); + expect(await screen.findByText(/does not use operator-supplied hosts/i)).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — updates and consent', () => { + it('FE-MOB-PLUGP-046: the updates bar updates every outdated plugin at once', async () => { + const updated: string[] = []; + mockPanel( + [plugin({ id: 'a', name: 'Aaa', version: '1.0.0' }), plugin({ id: 'b', name: 'Bbb', version: '1.0.0' })], + [registryEntry({ id: 'a', name: 'Aaa', latest: '2.0.0' }), registryEntry({ id: 'b', name: 'Bbb', latest: '1.1.0' })], + ); + server.use(http.post('*/api/admin/plugins/:id/update', ({ params }) => { + updated.push(String(params.id)); + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }); + })); + render(); + + expect(await screen.findByText('2 updates available for your plugins.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Update all' })); + await waitFor(() => expect(updated.sort()).toEqual(['a', 'b'])); + await waitFor(() => expect(toastMessages()).toContain('Plugin updated')); + }); + + it('FE-MOB-PLUGP-047: a per-row update that widens rights opens the consent sheet and approving activates with consent', async () => { + let consentBody: unknown = null; + mockPanel([plugin({ source_repo: 'acme/gotify', signed: true, version: '1.0.0' })], [registryEntry()]); + server.use( + http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips', 'x:unknown'], newEgress: ['api.acme.test'] })), + http.post('*/api/admin/plugins/trek-gotify/activate', async ({ request }) => { + consentBody = await request.json(); + return HttpResponse.json({ ok: true }); + }), + ); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' })); + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument(); + // A known permission reads as prose, an unknown one as its raw code. + expect(screen.getByText('Read trips the acting user can access')).toBeInTheDocument(); + expect(screen.getByText('x:unknown')).toBeInTheDocument(); + expect(screen.getByText('api.acme.test')).toBeInTheDocument(); + // The registry entry is signed, so no unsigned caveat. + expect(screen.queryByText(/Nothing ties this version to its author/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Approve & turn on' })); + await waitFor(() => expect(consentBody).toEqual({ consent: true })); + }); + + it('FE-MOB-PLUGP-048: "Keep off for now" drops the prompt and says the update stays off', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' })); + fireEvent.click(await screen.findByRole('button', { name: 'Keep off for now' })); + + await waitFor(() => expect(toastMessages()).toContain('Update installed — left off until you approve the new permissions')); + await waitFor(() => expect(screen.queryByText('This update needs new permissions')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-049: an unsigned update says nothing ties it to its author', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify', signed: false, version: '1.0.0' })], [registryEntry({ signed: false })]); + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' })); + expect(await screen.findByText(/Nothing ties this version to its author/)).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-050: a failed update reports the server message', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0' })], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ error: 'registry unreachable' }, { status: 502 }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' })); + await waitFor(() => expect(toastMessages()).toContain('registry unreachable')); + }); +}); + +describe('MAdminPluginsPanel — signature refusals', () => { + const blocked = (code: string) => plugin({ + source_repo: 'acme/gotify', signed: true, keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0', + updateBlock: { code, detail: 'the signing key changed', version: '2.0.0' }, + }); + + it('FE-MOB-PLUGP-051: the row keeps showing why the update was refused', async () => { + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]); + render(); + + expect(await screen.findByText('Update blocked — the signing key changed')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-052: the block goes quiet once the registry offers a newer version', async () => { + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry({ latest: '3.0.0' })]); + render(); + + await screen.findByText('Gotify'); + await waitFor(() => expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-053: a sideloaded plugin never shows an update block', async () => { + mockPanel([plugin({ source_repo: 'local:upload', updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' } })]); + render(); + + await screen.findByText('Sideloaded'); + expect(screen.queryByText(/Update blocked/)).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-054: Review compares both fingerprints and the confirmation re-pins and updates in one call', async () => { + let body: unknown = null; + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] }); + })); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + expect(await screen.findByText('Key it was installed with')).toBeInTheDocument(); + expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument(); + // Fingerprint of the registry's new key: head…tail of the base64 payload. + expect(screen.getByText('RWQ12345…87654321')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Trust the new key & update' })); + await waitFor(() => expect(body).toEqual({ + version: '2.0.0', + publicKey: 'untrusted comment: minisign public key\nRWQ12345678MIDDLE87654321', + })); + await waitFor(() => expect(toastMessages()).toContain('New signing key trusted — the plugin is updated')); + }); + + it('FE-MOB-PLUGP-055: a re-trust whose update widens rights still asks for consent', async () => { + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ version: '2.0.0', activated: false, newPermissions: ['db:read:trips'], newEgress: [] }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' })); + + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-056: a rejected re-trust reports the server message', async () => { + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/retrust', () => + HttpResponse.json({ error: 'key changed again' }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Trust the new key & update' })); + await waitFor(() => expect(toastMessages()).toContain('key changed again')); + }); + + it('FE-MOB-PLUGP-057: an invalid signature is explained and offers no override at all', async () => { + mockPanel([blocked('SIGNATURE_INVALID')], [registryEntry()]); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + expect(await screen.findByText(/do not match the author's signature/i)).toBeInTheDocument(); + // The raw server detail is shown instead of a key comparison that would imply a choice. + expect(screen.getByText('the signing key changed')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument(); + expect(screen.queryByText('Key it is offering now')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-058: a missing and a half-signed signature each get their own explanation', async () => { + mockPanel([blocked('SIGNATURE_MISSING')], [registryEntry()]); + const { unmount } = render(); + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + expect(await screen.findByText(/ships no signature/i)).toBeInTheDocument(); + unmount(); + + mockPanel([blocked('SIGNATURE_INCOMPLETE')], [registryEntry()]); + render(); + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + expect(await screen.findByText(/half-signed/i)).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-059: an update refused on its signature opens the dialog instead of a toast', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify', version: '1.0.0', keyFingerprint: 'OLDKEYaa…aaaaaaaa' })], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/update', () => + HttpResponse.json({ error: 'signing key changed', code: 'SIGNATURE_KEY_CHANGED' }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Update → v2.0.0' })); + expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument(); + expect(toastMessages()).not.toContain('signing key changed'); + }); + + it('FE-MOB-PLUGP-060: a fresh install refused for an invalid signature names the plugin from the registry entry', async () => { + mockPanel([], [registryEntry()]); + server.use(http.post('*/api/admin/plugins/install', () => + HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 }))); + render(); + + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + fireEvent.click(await screen.findByRole('button', { name: /^Install$/ })); + + expect(await screen.findByText("Gotify's signature could not be verified")).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-061: the dialog closes again without re-trusting', async () => { + mockPanel([blocked('SIGNATURE_KEY_CHANGED')], [registryEntry()]); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + fireEvent.click(await screen.findByRole('button', { name: 'Do not trust it' })); + await waitFor(() => expect(screen.queryByText('Key it was installed with')).not.toBeInTheDocument()); + }); +}); + +describe('MAdminPluginsPanel — enabling a plugin', () => { + const off = (over: Row = {}) => plugin({ enabled: 0, status: 'inactive', ...over }); + + it('FE-MOB-PLUGP-062: the toggle turns an active plugin off', async () => { + let called = false; + mockPanel([plugin()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/deactivate', () => { called = true; return HttpResponse.json({ ok: true }); })); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + await waitFor(() => expect(called).toBe(true)); + expect(toastMessages()).toContain('Plugin deactivated'); + }); + + it('FE-MOB-PLUGP-063: enabling a plugin also reports the dependencies it switched on for you', async () => { + // Named so the parent sorts first — the rows are ordered by name, and the toggle is picked by position. + mockPanel([ + off({ dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^1.0.0' }] } }), + plugin({ id: 'trek-base', name: 'Zzz Base', enabled: 0, status: 'inactive' }), + ]); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => HttpResponse.json({ ok: true }))); + render(); + + await screen.findByText('Gotify'); + fireEvent.click(screen.getAllByRole('switch', { name: 'Enable plugin' })[0]); + await waitFor(() => expect(toastMessages()).toContain('Enabled required plugin(s) first: Zzz Base')); + expect(toastMessages()).toContain('Plugin activated'); + }); + + it('FE-MOB-PLUGP-064: a 409 CONSENT_REQUIRED routes to the consent sheet', async () => { + mockPanel([off({ source_repo: 'acme/gotify', signed: false })], []); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'consent required', code: 'CONSENT_REQUIRED', newPermissions: ['db:read:trips'], newEgress: [] }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + expect(await screen.findByText('This update needs new permissions')).toBeInTheDocument(); + // The registry is empty here, so the caveat falls back to the installed row's own flag. + expect(screen.getByText(/Nothing ties this version to its author/)).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-065: a 409 ADDON_DISABLED names the addons to switch on first', async () => { + mockPanel([off()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'addon disabled', code: 'ADDON_DISABLED', addons: ['budget', 'vacay'] }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + await waitFor(() => expect(toastMessages()).toContain('Enable the required addon(s) first: budget, vacay')); + }); + + it('FE-MOB-PLUGP-066: any other activation failure surfaces the server message', async () => { + mockPanel([off()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'dependency cycle', code: 'DEPENDENCY_CYCLE' }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + await waitFor(() => expect(toastMessages()).toContain('dependency cycle')); + }); + + it('FE-MOB-PLUGP-067: a 409 DEPENDENCY_MISSING opens the resolve sheet, downloads and retries', async () => { + let installBody: unknown = null; + let activateCalls = 0; + mockPanel([off()]); + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => { + activateCalls += 1; + if (activateCalls === 1) { + return HttpResponse.json({ + error: 'missing dependency', code: 'DEPENDENCY_MISSING', + missing: [{ id: 'trek-base', version: '^1.0.0' }], + versionMismatch: [{ id: 'trek-old', wanted: '^2.0.0', installed: '1.0.0' }], + }, { status: 409 }); + } + return HttpResponse.json({ ok: true }); + }), + http.post('*/api/admin/plugins/install', async ({ request }) => { + installBody = await request.json(); + return HttpResponse.json({ installed: ['trek-base'], requiredAddons: ['budget'] }); + }), + ); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + expect(await screen.findByText('Missing dependencies')).toBeInTheDocument(); + expect(screen.getByText('Requires ^1.0.0')).toBeInTheDocument(); + expect(screen.getByText('Needs ^2.0.0 — 1.0.0 is installed')).toBeInTheDocument(); + expect(screen.getByText('Downloads the latest compatible version, including its own dependencies.')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /Download/ })); + await waitFor(() => expect(installBody).toEqual({ id: 'trek-base', constraint: '^1.0.0', withDependencies: true })); + await waitFor(() => expect(toastMessages()).toContain('Downloaded trek-base')); + // The dependency install revealed a disabled addon on top. + expect(toastMessages()).toContain('Enable the required addon(s) first: budget'); + await waitFor(() => expect(activateCalls).toBe(2)); + }); + + it('FE-MOB-PLUGP-068: a signature refusal while downloading a dependency names the dependency', async () => { + mockPanel([off()]); + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })), + http.post('*/api/admin/plugins/install', () => + HttpResponse.json({ error: 'author signature verification failed', code: 'SIGNATURE_INVALID' }, { status: 400 })), + ); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + fireEvent.click(await screen.findByRole('button', { name: /Download/ })); + + expect(await screen.findByText("trek-base's signature could not be verified")).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-069: the resolve sheet can be dismissed without downloading', async () => { + mockPanel([off()]); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 }))); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + fireEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()); + }); +}); + +describe('MAdminPluginsPanel — sideloading, dev-link and rescan', () => { + it('FE-MOB-PLUGP-070: the toolbar upload button installs the picked archive', async () => { + let uploaded = false; + mockPanel([]); + server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'trek-new' }); })); + const { container } = render(); + await screen.findByText('No plugins installed yet.'); + + fireEvent.click(screen.getByRole('button', { name: 'Upload plugin' })); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip', { type: 'application/zip' })] } }); + + await waitFor(() => expect(uploaded).toBe(true)); + await waitFor(() => expect(toastMessages()).toContain('Plugin “trek-new” uploaded — activate it to run')); + }); + + it('FE-MOB-PLUGP-071: a rejected upload reports the server message', async () => { + mockPanel([]); + server.use(http.post('*/api/admin/plugins/upload', () => + HttpResponse.json({ error: 'manifest missing' }, { status: 400 }))); + const { container } = render(); + await screen.findByText('No plugins installed yet.'); + + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['zip'], 'plugin.zip')] } }); + await waitFor(() => expect(toastMessages()).toContain('manifest missing')); + }); + + it('FE-MOB-PLUGP-072: dragging an archive over the panel offers to install it, and dropping does', async () => { + let uploaded = false; + mockPanel([]); + server.use(http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'dropped' }); })); + const { container } = render(); + await screen.findByText('No plugins installed yet.'); + + const panel = container.firstElementChild as HTMLElement; + const file = new File(['zip'], 'plugin.zip'); + fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [file] } }); + expect(await screen.findByText('Drop a plugin .zip to install')).toBeInTheDocument(); + + fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [file] } }); + await waitFor(() => expect(uploaded).toBe(true)); + await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-073: dragging something that is not a file never arms the overlay', async () => { + mockPanel([]); + const { container } = render(); + await screen.findByText('No plugins installed yet.'); + + const panel = container.firstElementChild as HTMLElement; + fireEvent.dragEnter(panel, { dataTransfer: { types: ['text/plain'], files: [] } }); + expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-074: leaving the panel again disarms the overlay', async () => { + mockPanel([]); + const { container } = render(); + await screen.findByText('No plugins installed yet.'); + + const panel = container.firstElementChild as HTMLElement; + fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } }); + await screen.findByText('Drop a plugin .zip to install'); + fireEvent.dragOver(panel, { dataTransfer: { types: ['Files'], files: [] } }); + fireEvent.dragLeave(panel); + await waitFor(() => expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-075: the dev-link form registers a local build directory', async () => { + let body: unknown = null; + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])), + http.post('*/api/admin/plugins/link', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ id: 'local-plugin' }); + }), + ); + render(); + + const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin'); + // An empty path is a no-op, not a request. + fireEvent.submit(input.closest('form') as HTMLFormElement); + expect(body).toBeNull(); + + fireEvent.change(input, { target: { value: '/srv/plugins/local' } }); + fireEvent.click(screen.getByRole('button', { name: /^Link$/ })); + + await waitFor(() => expect(body).toEqual({ path: '/srv/plugins/local' })); + await waitFor(() => expect(toastMessages()).toContain('Linked local-plugin — activate it to run')); + }); + + it('FE-MOB-PLUGP-076: a rejected dev-link reports the server message', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: true, plugins: [] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json([])), + http.post('*/api/admin/plugins/link', () => HttpResponse.json({ error: 'no manifest there' }, { status: 400 })), + ); + render(); + + const input = await screen.findByPlaceholderText('/absolute/path/to/your/plugin'); + fireEvent.change(input, { target: { value: '/nope' } }); + fireEvent.click(screen.getByRole('button', { name: /^Link$/ })); + await waitFor(() => expect(toastMessages()).toContain('no manifest there')); + }); + + it('FE-MOB-PLUGP-077: Rescan rediscovers local plugins and force-pulls the registry', async () => { + let rescanned = false; + let refreshParam: string | null = null; + mockPanel([]); + server.use( + http.post('*/api/admin/plugins/rescan', () => { rescanned = true; return HttpResponse.json({ ok: true }); }), + http.get('*/api/admin/plugins/registry', ({ request }) => { + refreshParam = new URL(request.url).searchParams.get('refresh'); + return HttpResponse.json([registryEntry()]); + }), + ); + render(); + await screen.findByText('No plugins installed yet.'); + + fireEvent.click(screen.getByRole('button', { name: 'Rescan' })); + await waitFor(() => expect(rescanned).toBe(true)); + await waitFor(() => expect(refreshParam).toBe('1')); + expect(toastMessages()).toContain('Rescanned the plugins folder'); + }); + + it('FE-MOB-PLUGP-078: a failed rescan falls back to the generic action error', async () => { + mockPanel([]); + server.use(http.post('*/api/admin/plugins/rescan', () => HttpResponse.json({}, { status: 500 }))); + render(); + await screen.findByText('No plugins installed yet.'); + + fireEvent.click(screen.getByRole('button', { name: 'Rescan' })); + await waitFor(() => expect(toastMessages()).toContain('Action failed')); + }); +}); + +describe('MAdminPluginsPanel — edge paths', () => { + it('FE-MOB-PLUGP-080: a block stands while the registry is unreachable, and offers no override without the new key', async () => { + mockPanel([plugin({ + source_repo: 'acme/gotify', signed: true, keyFingerprint: null, version: '1.0.0', + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'the signing key changed', version: '2.0.0' }, + })], []); + render(); + + // Nothing proves the block stale, so it must not be dropped. + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + // Neither fingerprint is knowable: no pinned key, and no registry entry to read the new one from. + expect(await screen.findAllByText('—')).toHaveLength(2); + expect(screen.queryByRole('button', { name: 'Trust the new key & update' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-081: a key with no payload line has no fingerprint to show', async () => { + mockPanel([plugin({ + source_repo: 'acme/gotify', keyFingerprint: 'OLDKEYaa…aaaaaaaa', version: '1.0.0', + updateBlock: { code: 'SIGNATURE_KEY_CHANGED', detail: 'x', version: '2.0.0' }, + })], [registryEntry({ authorPublicKey: 'untrusted comment: minisign public key' })]); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Review' })); + expect(await screen.findByText('—')).toBeInTheDocument(); + expect(screen.getByText('OLDKEYaa…aaaaaaaa')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-082: an outdated dependency is flagged amber just like a missing one', async () => { + mockPanel([plugin({ + dependencies: { requiredAddons: [], pluginDependencies: [{ id: 'trek-base', version: '^2.0.0' }] }, + dependencyIssues: { disabledAddons: [], missing: [], versionMismatch: [{ id: 'trek-base', wanted: '^2.0.0', installed: '1.0.0' }] }, + })]); + render(); + + expect(await screen.findByText('Needs trek-base ^2.0.0')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-083: switching to Discover drops the installed-only sort key', async () => { + mockPanel([plugin()], [registryEntry()]); + render(); + await screen.findByText('Gotify'); + + fireEvent.click(screen.getByTitle('Sort: Name')); + fireEvent.click(await screen.findByRole('button', { name: 'Updates first' })); + await waitFor(() => expect(screen.getByTitle('Sort: Updates first')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('tab', { name: /Discover/ })); + await waitFor(() => expect(screen.getByTitle('Sort: Name')).toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-084: dropping an archive while the runtime is off installs nothing', async () => { + let uploaded = false; + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: false, plugins: [] })), + http.post('*/api/admin/plugins/upload', () => { uploaded = true; return HttpResponse.json({ id: 'x' }); }), + ); + const { container } = render(); + await screen.findByText('Plugins are disabled'); + + const panel = container.firstElementChild as HTMLElement; + fireEvent.dragEnter(panel, { dataTransfer: { types: ['Files'], files: [] } }); + expect(screen.queryByText('Drop a plugin .zip to install')).not.toBeInTheDocument(); + + fireEvent.drop(panel, { dataTransfer: { types: ['Files'], files: [new File(['zip'], 'p.zip')] } }); + await waitFor(() => expect(screen.getByText('Plugins are disabled')).toBeInTheDocument()); + expect(uploaded).toBe(false); + }); + + it('FE-MOB-PLUGP-085: an ordinary failure downloading a dependency falls back to the activation error', async () => { + mockPanel([plugin({ enabled: 0, status: 'inactive' })]); + server.use( + http.post('*/api/admin/plugins/trek-gotify/activate', () => + HttpResponse.json({ error: 'missing dependency', code: 'DEPENDENCY_MISSING', missing: [{ id: 'trek-base', version: '^1.0.0' }], versionMismatch: [] }, { status: 409 })), + http.post('*/api/admin/plugins/install', () => HttpResponse.json({ error: 'registry unreachable' }, { status: 502 })), + ); + render(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Enable plugin' })); + fireEvent.click(await screen.findByRole('button', { name: /Download/ })); + await waitFor(() => expect(toastMessages()).toContain('registry unreachable')); + }); + + it('FE-MOB-PLUGP-086: every sheet closes from its own close button and from Escape', async () => { + mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true, egressHostCount: 1 })]); + server.use( + http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] })), + http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: true, hosts: [] })), + ); + render(); + + // Row action sheet → its own close button. + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByText('View error log')).not.toBeInTheDocument()); + + // Error log → Escape, then the close button. + fireEvent.click(screen.getByTestId('plugin-row-menu-btn-trek-gotify')); + fireEvent.click(screen.getByText('View error log')); + await screen.findByText('No errors logged.'); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByText('No errors logged.')).not.toBeInTheDocument()); + + // Allowed hosts → Escape, from the row chip. + fireEvent.click(screen.getByRole('button', { name: '1 allowed host(s)' })); + await screen.findByText('No hosts added yet.'); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByText('No hosts added yet.')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-087: the hosts sheet closes from its own close button too', async () => { + mockPanel([plugin({ operatorEgress: true, egressHostCount: 0 })]); + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: [] }))); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'Add allowed host' })); + await screen.findByText('No hosts added yet.'); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByText('No hosts added yet.')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-088: the error log sheet closes from its own close button too', async () => { + mockPanel([plugin()]); + server.use(http.get('*/api/admin/plugins/trek-gotify/errors', () => HttpResponse.json({ errors: [] }))); + render(); + + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + fireEvent.click(screen.getByText('View error log')); + await screen.findByText('No errors logged.'); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Close' })); + await waitFor(() => expect(screen.queryByText('No errors logged.')).not.toBeInTheDocument()); + }); + + it('FE-MOB-PLUGP-090: an unreachable registry behind an installed list costs only the update badges', async () => { + server.use( + http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, plugins: [plugin({ version: '1.0.0' })] })), + http.get('*/api/admin/plugins/registry', () => HttpResponse.json({ error: 'down' }, { status: 500 })), + ); + render(); + + expect(await screen.findByText('Gotify')).toBeInTheDocument(); + expect(screen.queryByText(/updates available/)).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-091: a second tap on the toggle while the first is in flight is ignored', async () => { + let calls = 0; + let release: () => void = () => {}; + const gate = new Promise(resolve => { release = resolve; }); + mockPanel([plugin({ enabled: 0, status: 'inactive' })]); + server.use(http.post('*/api/admin/plugins/trek-gotify/activate', async () => { + calls += 1; + await gate; + return HttpResponse.json({ ok: true }); + })); + render(); + + const toggle = await screen.findByRole('switch', { name: 'Enable plugin' }); + fireEvent.click(toggle); + fireEvent.click(toggle); + release(); + + await waitFor(() => expect(toastMessages()).toContain('Plugin activated')); + expect(calls).toBe(1); + }); + + it('FE-MOB-PLUGP-089: six-figure download counts round into millions', async () => { + mockPanel([], [registryEntry({ downloadCount: 1_200_000 })]); + render(); + fireEvent.click(await screen.findByRole('tab', { name: /Discover/ })); + + expect(await screen.findByText('1.2M')).toBeInTheDocument(); + }); +}); + +describe('MAdminPluginsPanel — the security footer', () => { + it('FE-MOB-PLUGP-079: expands the containment explainer on demand', async () => { + mockPanel([]); + render(); + await screen.findByText('No plugins installed yet.'); + + expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('How plugins are contained — and the limits')); + + expect(screen.getByText('Every plugin runs boxed in')).toBeInTheDocument(); + expect(screen.getByText('The worst case')).toBeInTheDocument(); + expect(screen.getByText('What "Signed" means')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('How plugins are contained — and the limits')); + expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx b/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx new file mode 100644 index 000000000..1b18606df --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminSettingsSection.test.tsx @@ -0,0 +1,422 @@ +// FE-MOB-ASET-001 to FE-MOB-ASET-023 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { buildAdminHook, type AdminHook } from '../../../helpers/mobileAdmin'; +import { useTranslation } from '../../../../src/i18n'; +import MAdminSettingsSection from '../../../../src/mobile/screens/admin/MAdminSettingsSection'; + +function Harness({ admin }: { admin: AdminHook }) { + const { t } = useTranslation(); + return ; +} + +type Spy = ReturnType; +type Spies = Record & { toast: Record }; + +function renderSettings(overrides: Record = {}) { + const admin = buildAdminHook(overrides); + render(); + return admin as unknown as Spies; +} + +function toggle(label: string): HTMLElement { + return screen.getByRole('switch', { name: label }); +} + +/** Client ID / Client Secret are the only inputs of their type without a placeholder. */ +function unlabelledInput(type: 'text' | 'password'): HTMLInputElement { + const inputs = Array.from(document.querySelectorAll(`input[type="${type}"]`)); + const match = inputs.find((i) => !i.placeholder); + if (!match) throw new Error(`no ${type} input without a placeholder`); + return match; +} + +/** + * setOidcConfig receives an updater that reads e.target.value lazily, so the + * spy fixture can't be replayed after the event — this harness holds the config + * in real state instead. + */ +function OidcHarness() { + const { t } = useTranslation(); + const [oidcConfig, setOidcConfig] = React.useState({ + issuer: '', + client_id: '', + client_secret: '', + client_secret_set: false, + display_name: '', + discovery_url: '', + }); + const admin = buildAdminHook({ oidcConfig, setOidcConfig }); + return ; +} + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminSettingsSection', () => { + it('FE-MOB-ASET-001: renders every card of the settings tab', () => { + renderSettings(); + + expect(screen.getByText('Authentication Methods')).toBeInTheDocument(); + expect(screen.getByText('Passkey login')).toBeInTheDocument(); + expect(screen.getByText('Require two-factor authentication (2FA)')).toBeInTheDocument(); + // Card head and field label share the copy + expect(screen.getAllByText('Allowed File Types')).toHaveLength(2); + expect(screen.getByText('API Keys')).toBeInTheDocument(); + expect(screen.getByText('Single Sign-On (OIDC)')).toBeInTheDocument(); + expect(screen.getByText('Danger Zone')).toBeInTheDocument(); + }); + + it('FE-MOB-ASET-002: the OIDC toggles only appear once OIDC is configured', () => { + renderSettings({ oidcConfigured: false }); + + expect(screen.queryByRole('switch', { name: 'SSO Login' })).not.toBeInTheDocument(); + expect(screen.queryByRole('switch', { name: 'SSO Auto-Provisioning' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-ASET-003: an env override disables the password toggles and shows the hint', () => { + renderSettings({ envOverrideOidcOnly: true, oidcConfigured: true }); + + expect( + screen.getByText( + 'Password login settings are controlled by the OIDC_ONLY environment variable and cannot be changed here.', + ), + ).toBeInTheDocument(); + expect(toggle('Password Login')).toBeDisabled(); + expect(toggle('Password Registration')).toBeDisabled(); + expect(toggle('SSO Login')).not.toBeDisabled(); + }); + + it('FE-MOB-ASET-004: the last remaining login method cannot be switched off', () => { + renderSettings({ passwordLogin: false, oidcLogin: false, oidcConfigured: true }); + + expect(toggle('Password Login')).toBeDisabled(); + expect(toggle('SSO Login')).not.toBeDisabled(); + }); + + it('FE-MOB-ASET-005: each auth toggle reports its own key and the new value', async () => { + const user = userEvent.setup(); + const admin = renderSettings({ oidcConfigured: true, oidcLogin: true, oidcRegistration: false }); + + await user.click(toggle('Password Login')); + expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith( + 'password_login', false, admin.setPasswordLogin, + ); + + await user.click(toggle('Password Registration')); + expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith( + 'password_registration', false, admin.setPasswordRegistration, + ); + + await user.click(toggle('SSO Login')); + expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('oidc_login', false, admin.setOidcLogin); + + await user.click(toggle('SSO Auto-Provisioning')); + expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith( + 'oidc_registration', true, admin.setOidcRegistration, + ); + + await user.click(toggle('Enable passkey login')); + expect(admin.handleToggleAuthSetting).toHaveBeenCalledWith('passkey_login', true, admin.setPasskeyLogin); + }); + + it('FE-MOB-ASET-006: the require-MFA toggle calls its dedicated handler', async () => { + const user = userEvent.setup(); + const admin = renderSettings({ requireMfa: false }); + + await user.click(toggle('Require two-factor authentication (2FA)')); + + expect(admin.handleToggleRequireMfa).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-ASET-007: the passkey warning shows only when enabled but unconfigured', () => { + const { unmount } = render( + , + ); + expect( + screen.getByText( + 'No WebAuthn domain resolves for this deployment yet. Set APP_URL or the Relying Party ID below — passkeys stay hidden until then.', + ), + ).toBeInTheDocument(); + unmount(); + + render(); + expect(screen.queryByText(/No WebAuthn domain resolves/)).not.toBeInTheDocument(); + }); + + it('FE-MOB-ASET-008: the WebAuthn fields render their values and save through the hook', async () => { + const user = userEvent.setup(); + const admin = renderSettings({ + webauthnRpId: 'trek.example.org', + webauthnOrigins: 'https://trek.example.org', + }); + + expect(screen.getByPlaceholderText('trek.example.org')).toHaveValue('trek.example.org'); + expect(screen.getByPlaceholderText('https://trek.example.org')).toHaveValue('https://trek.example.org'); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[0]); + expect(admin.handleSaveWebauthn).toHaveBeenCalled(); + }); + + it('FE-MOB-ASET-009: editing the RP ID and origins goes through their setters', async () => { + const user = userEvent.setup(); + const admin = renderSettings(); + + await user.type(screen.getByPlaceholderText('trek.example.org'), 'a'); + expect(admin.setWebauthnRpId).toHaveBeenCalledWith('a'); + + await user.type(screen.getByPlaceholderText('https://trek.example.org'), 'b'); + expect(admin.setWebauthnOrigins).toHaveBeenCalledWith('b'); + }); + + it('FE-MOB-ASET-010: saving the allowed file types PUTs them and reports success', async () => { + const user = userEvent.setup(); + let sent: Record = {}; + server.use( + http.put('/api/auth/app-settings', async ({ request }) => { + sent = (await request.json()) as Record; + return HttpResponse.json(sent); + }), + ); + const admin = renderSettings({ allowedFileTypes: 'jpg,png' }); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + + await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('File type settings saved')); + expect(sent).toEqual({ allowed_file_types: 'jpg,png' }); + expect(admin.setSavingFileTypes).toHaveBeenNthCalledWith(1, true); + expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false); + }); + + it('FE-MOB-ASET-011: a failing file-types save toasts the generic error', async () => { + const user = userEvent.setup(); + server.use( + http.put('/api/auth/app-settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + const admin = renderSettings(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[1]); + + await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error')); + expect(admin.setSavingFileTypes).toHaveBeenLastCalledWith(false); + }); + + it('FE-MOB-ASET-012: the file-types input writes back through its setter', async () => { + const user = userEvent.setup(); + const admin = renderSettings({ allowedFileTypes: '' }); + + await user.type(screen.getByPlaceholderText('jpg,png,pdf,doc,docx,xls,xlsx,txt,csv'), 'p'); + expect(admin.setAllowedFileTypes).toHaveBeenCalledWith('p'); + }); + + it('FE-MOB-ASET-013: the maps key validate button is disabled without a key', () => { + renderSettings({ mapsKey: '' }); + + expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled(); + }); + + it('FE-MOB-ASET-014: validating a maps key calls the handler for that key type', async () => { + const user = userEvent.setup(); + const admin = renderSettings({ mapsKey: 'AIza-test' }); + + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(admin.handleValidateKey).toHaveBeenCalledWith('maps'); + }); + + it('FE-MOB-ASET-015: the validation result renders as a valid or invalid line', () => { + const { unmount } = render( + , + ); + expect(screen.getByText('Connected')).toBeInTheDocument(); + expect(screen.queryByText('Invalid')).not.toBeInTheDocument(); + unmount(); + + render(); + expect(screen.getByText('Invalid')).toBeInTheDocument(); + }); + + it('FE-MOB-ASET-016: the API keys card saves through the hook', async () => { + const user = userEvent.setup(); + const admin = renderSettings(); + + await user.type(screen.getAllByPlaceholderText('Enter key...')[0], 'm'); + expect(admin.setMapsKey).toHaveBeenCalledWith('m'); + + await user.type(screen.getAllByPlaceholderText('Enter key...')[1], 'u'); + expect(admin.setUnsplashKey).toHaveBeenCalledWith('u'); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[2]); + expect(admin.handleSaveApiKeys).toHaveBeenCalled(); + }); + + it('FE-MOB-ASET-017: the Google Places toggles persist optimistically', async () => { + const user = userEvent.setup(); + const seen: string[] = []; + server.use( + http.put('/api/admin/places-photos', () => { + seen.push('photos'); + return HttpResponse.json({ enabled: true }); + }), + http.put('/api/admin/places-autocomplete', () => { + seen.push('autocomplete'); + return HttpResponse.json({ enabled: true }); + }), + http.put('/api/admin/places-details', () => { + seen.push('details'); + return HttpResponse.json({ enabled: true }); + }), + ); + const admin = renderSettings(); + + await user.click(toggle('Place Photos')); + await user.click(toggle('Place Autocomplete')); + await user.click(toggle('Place Details')); + + await waitFor(() => expect(seen).toEqual(['photos', 'autocomplete', 'details'])); + expect(admin.setPlacesPhotosEnabledState).toHaveBeenCalledWith(true); + expect(admin.setPlacesPhotosEnabled).toHaveBeenCalledWith(true); + expect(admin.setPlacesAutocompleteEnabled).toHaveBeenCalledWith(true); + expect(admin.setPlacesDetailsEnabled).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-ASET-018: a rejected Places update rolls the toggle back', async () => { + const user = userEvent.setup(); + server.use( + http.put('/api/admin/places-photos', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + http.put('/api/admin/places-autocomplete', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + http.put('/api/admin/places-details', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + const admin = renderSettings({ + placesPhotosEnabled: true, + placesAutocompleteEnabled: true, + placesDetailsEnabled: true, + }); + + await user.click(toggle('Place Photos')); + await user.click(toggle('Place Autocomplete')); + await user.click(toggle('Place Details')); + + await waitFor(() => expect(admin.setPlacesPhotosEnabledState).toHaveBeenLastCalledWith(true)); + expect(admin.setPlacesPhotosEnabled).toHaveBeenLastCalledWith(true); + expect(admin.setPlacesAutocompleteEnabledState).toHaveBeenLastCalledWith(true); + expect(admin.setPlacesDetailsEnabledState).toHaveBeenLastCalledWith(true); + }); + + it('FE-MOB-ASET-019: the Open-Meteo card lists the three facts', () => { + renderSettings(); + + expect(screen.getByText('Weather Data')).toBeInTheDocument(); + expect(screen.getByText('Free, no API key required')).toBeInTheDocument(); + expect(screen.getByText('16-day forecast')).toBeInTheDocument(); + expect(screen.getByText('10,000 requests / day')).toBeInTheDocument(); + }); + + it('FE-MOB-ASET-020: the OIDC form renders its values and saves a payload without an empty secret', async () => { + const user = userEvent.setup(); + let sent: Record = {}; + server.use( + http.put('/api/admin/oidc', async ({ request }) => { + sent = (await request.json()) as Record; + return HttpResponse.json(sent); + }), + ); + const admin = renderSettings({ + oidcConfig: { + issuer: 'https://idp.example.org', + client_id: 'trek', + client_secret: '', + client_secret_set: true, + display_name: 'Authentik', + discovery_url: 'https://idp.example.org/.well-known/openid-configuration', + }, + }); + + expect(screen.getByDisplayValue('Authentik')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://idp.example.org')).toBeInTheDocument(); + expect(screen.getByDisplayValue('trek')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument(); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[3]); + + await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('OIDC configuration saved')); + expect(sent).toEqual({ + issuer: 'https://idp.example.org', + client_id: 'trek', + display_name: 'Authentik', + discovery_url: 'https://idp.example.org/.well-known/openid-configuration', + }); + expect(admin.setSavingOidc).toHaveBeenLastCalledWith(false); + }); + + it('FE-MOB-ASET-021: a filled secret is sent and a server error message is surfaced', async () => { + const user = userEvent.setup(); + let sent: Record = {}; + server.use( + http.put('/api/admin/oidc', async ({ request }) => { + sent = (await request.json()) as Record; + return HttpResponse.json({ error: 'Issuer unreachable' }, { status: 400 }); + }), + ); + const admin = renderSettings({ + oidcConfig: { + issuer: 'https://idp.example.org', + client_id: 'trek', + client_secret: 's3cr3t', + client_secret_set: false, + display_name: 'Authentik', + discovery_url: '', + }, + }); + + await user.click(screen.getAllByRole('button', { name: 'Save' })[3]); + + await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Issuer unreachable')); + expect(sent.client_secret).toBe('s3cr3t'); + }); + + it('FE-MOB-ASET-022: every OIDC input patches only its own field', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak'), 'Authentik'); + await user.type(screen.getByPlaceholderText('https://accounts.google.com'), 'https://idp'); + await user.type( + screen.getByPlaceholderText( + 'https://auth.example.com/application/o/trek/.well-known/openid-configuration', + ), + 'https://idp/disco', + ); + await user.type(unlabelledInput('text'), 'trek'); + await user.type(unlabelledInput('password'), 's3cr3t'); + + expect(screen.getByPlaceholderText('z.B. Google, Authentik, Keycloak')).toHaveValue('Authentik'); + expect(screen.getByPlaceholderText('https://accounts.google.com')).toHaveValue('https://idp'); + expect( + screen.getByPlaceholderText( + 'https://auth.example.com/application/o/trek/.well-known/openid-configuration', + ), + ).toHaveValue('https://idp/disco'); + expect(unlabelledInput('text')).toHaveValue('trek'); + expect(unlabelledInput('password')).toHaveValue('s3cr3t'); + }); + + it('FE-MOB-ASET-023: the danger zone opens the rotate-JWT confirm', async () => { + const user = userEvent.setup(); + const admin = renderSettings(); + + await user.click(screen.getByRole('button', { name: 'Rotate' })); + + expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(true); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminSheets.test.tsx b/client/tests/unit/mobile/admin/MAdminSheets.test.tsx new file mode 100644 index 000000000..3b92c8eb3 --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminSheets.test.tsx @@ -0,0 +1,430 @@ +// FE-MOB-ASHEET-001 to FE-MOB-ASHEET-024 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin'; +import { useTranslation } from '../../../../src/i18n'; +import MAdminSheets from '../../../../src/mobile/screens/admin/MAdminSheets'; + +function Harness({ admin }: { admin: AdminHook }) { + const { t } = useTranslation(); + return ; +} + +type Spy = ReturnType; +type Spies = Record & { toast: Record }; + +function renderSheets(overrides: Record = {}) { + const admin = buildAdminHook(overrides); + render(); + return admin as unknown as Spies; +} + +interface UserForm { + username: string; + email: string; + role: string; + password: string; +} + +/** + * The form inputs read e.target.value inside the state updater, so a spy setter + * can't be inspected afterwards — React has already restored the controlled + * value by then. These sheets therefore get real state. + */ +function StatefulSheets({ + initialForm, + overrides, +}: { + initialForm: UserForm; + overrides: Record; +}) { + const { t } = useTranslation(); + const [form, setForm] = React.useState(initialForm); + const admin = buildAdminHook({ + ...overrides, + createForm: form, + setCreateForm: setForm, + editForm: form, + setEditForm: setForm, + }); + return ; +} + +const ME = buildAdminUser({ id: 1, username: 'admin' }); +const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' }); + +beforeEach(() => { + resetAllStores(); +}); + +afterEach(() => { + server.resetHandlers(); +}); + +describe('MAdminSheets', () => { + it('FE-MOB-ASHEET-001: nothing is rendered while every sheet is closed', () => { + renderSheets(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ASHEET-002: the create sheet shows the four fields prefilled from createForm', () => { + renderSheets({ + showCreateUser: true, + createForm: { username: 'newbie', email: 'new@example.com', password: 'secret12', role: 'admin' }, + }); + + expect(screen.getByRole('dialog', { name: 'Create User' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie'); + expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com'); + expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12'); + expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('FE-MOB-ASHEET-003: each create-form input writes through to its own field', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByPlaceholderText('Username'), 'newbie'); + await user.type(screen.getByPlaceholderText('Email'), 'new@example.com'); + await user.type(screen.getByPlaceholderText('Password'), 'secret12'); + + expect(screen.getByPlaceholderText('Username')).toHaveValue('newbie'); + expect(screen.getByPlaceholderText('Email')).toHaveValue('new@example.com'); + expect(screen.getByPlaceholderText('Password')).toHaveValue('secret12'); + }); + + it('FE-MOB-ASHEET-003b: the password eye toggles the input type', async () => { + const user = userEvent.setup(); + renderSheets({ showCreateUser: true }); + + const input = screen.getByPlaceholderText('Password'); + expect(input).toHaveAttribute('type', 'password'); + await user.click(screen.getByRole('button', { name: 'Show or hide' })); + expect(screen.getByPlaceholderText('Password')).toHaveAttribute('type', 'text'); + }); + + it('FE-MOB-ASHEET-004: the role segment writes the picked role', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ showCreateUser: true }); + + await user.click(screen.getByRole('tab', { name: 'Administrator' })); + const updater = admin.setCreateForm.mock.calls[0][0] as (f: Record) => Record; + expect(updater({ username: 'a', email: 'b', password: 'c', role: 'user' }).role).toBe('admin'); + }); + + it('FE-MOB-ASHEET-005: create sheet footer submits and cancels', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ showCreateUser: true }); + + await user.click(screen.getByRole('button', { name: 'Create User' })); + expect(admin.handleCreateUser).toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(admin.setShowCreateUser).toHaveBeenCalledWith(false); + }); + + it('FE-MOB-ASHEET-006: the edit sheet only renders its body once a user is being edited', () => { + renderSheets({ + editingUser: ALICE, + editForm: { username: 'alice', email: 'alice@example.com', role: 'user', password: '' }, + }); + + expect(screen.getByRole('dialog', { name: 'Edit User' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('alice')).toBeInTheDocument(); + expect(screen.getByDisplayValue('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Reset passkeys')).toBeInTheDocument(); + }); + + it('FE-MOB-ASHEET-007: the edit form writes username, email, password and role separately', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.type(screen.getByDisplayValue('alice'), 'x'); + await user.type(screen.getByDisplayValue('alice@example.com'), '.uk'); + await user.type(screen.getByPlaceholderText('Enter new password…'), 'newsecret'); + await user.click(screen.getByRole('tab', { name: 'Administrator' })); + + expect(screen.getByDisplayValue('alicex')).toBeInTheDocument(); + expect(screen.getByDisplayValue('alice@example.com.uk')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter new password…')).toHaveValue('newsecret'); + expect(screen.getByRole('tab', { name: 'Administrator' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('FE-MOB-ASHEET-008: saving and cancelling the edit sheet call through', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ editingUser: ALICE }); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(admin.handleSaveUser).toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(admin.setEditingUser).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-ASHEET-009: deleting closes the sheet first and then deletes that user', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ editingUser: ALICE, currentUser: ME }); + + const deleteBtn = screen.getByRole('button', { name: 'Delete user' }); + expect(deleteBtn).not.toBeDisabled(); + await user.click(deleteBtn); + + expect(admin.setEditingUser).toHaveBeenCalledWith(null); + expect(admin.handleDeleteUser).toHaveBeenCalledWith(ALICE); + }); + + it('FE-MOB-ASHEET-010: the delete button is disabled for the signed-in admin', () => { + renderSheets({ editingUser: ME, currentUser: ME }); + + expect(screen.getByRole('button', { name: 'Delete user' })).toBeDisabled(); + }); + + it('FE-MOB-ASHEET-011: resetting passkeys confirms, calls the API and reports the count', async () => { + const user = userEvent.setup(); + let hitId = ''; + server.use( + http.delete('/api/admin/users/:id/passkeys', ({ params }) => { + hitId = String(params.id); + return HttpResponse.json({ deleted: 3 }); + }), + ); + const admin = renderSheets({ editingUser: ALICE }); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + const confirm = screen.getByRole('dialog', { name: 'Reset passkeys' }); + expect(confirm).toBeInTheDocument(); + expect(screen.getByText('Remove all passkeys for alice?')).toBeInTheDocument(); + + await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]); + + await waitFor(() => expect(admin.toast.success).toHaveBeenCalledWith('Removed 3 passkey(s)')); + expect(hitId).toBe('2'); + }); + + it('FE-MOB-ASHEET-012: a failing passkey reset toasts the error and keeps the sheet open', async () => { + const user = userEvent.setup(); + server.use( + http.delete('/api/admin/users/:id/passkeys', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + const admin = renderSheets({ editingUser: ALICE }); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + await user.click(screen.getAllByRole('button', { name: 'Reset passkeys' })[1]); + + await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error')); + expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument(); + }); + + it('FE-MOB-ASHEET-013: the passkey confirm can be dismissed without calling the API', async () => { + const user = userEvent.setup(); + let called = false; + server.use( + http.delete('/api/admin/users/:id/passkeys', () => { + called = true; + return HttpResponse.json({ deleted: 0 }); + }), + ); + renderSheets({ editingUser: ALICE }); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]); + + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(), + ); + expect(called).toBe(false); + }); + + it('FE-MOB-ASHEET-014: the update sheet shows the docker commands for a docker install', () => { + renderSheets({ + showUpdateModal: true, + updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true }, + }); + + expect(screen.getByRole('dialog', { name: 'How to Update' })).toBeInTheDocument(); + expect(screen.getByText('v3.4.0 → v3.5.0')).toBeInTheDocument(); + expect(screen.getByText(/docker pull mauriceboe\/trek:latest/)).toBeInTheDocument(); + expect( + screen.getByText( + 'Your TREK instance runs in Docker. To update to v3.5.0, run the following commands on your server:', + ), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /update guide/ })).not.toBeInTheDocument(); + }); + + it('FE-MOB-ASHEET-015: a non-docker install links the wiki guide instead', () => { + renderSheets({ + showUpdateModal: true, + updateInfo: { + update_available: true, + current: '3.4.0', + latest: '3.5.0', + is_docker: false, + release_url: 'https://github.com/liketrek/TREK/releases/v3.5.0', + }, + }); + + expect(screen.getByRole('link', { name: 'Open the update guide' })).toHaveAttribute( + 'href', + 'https://github.com/mauriceboe/TREK/wiki/Updating', + ); + expect(screen.queryByText(/docker pull/)).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View on GitHub' })).toHaveAttribute( + 'href', + 'https://github.com/liketrek/TREK/releases/v3.5.0', + ); + }); + + it('FE-MOB-ASHEET-016: the update sheet closes from its footer', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ + showUpdateModal: true, + updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true }, + }); + + // Header icon button and footer button both close the sheet + const closeButtons = screen.getAllByRole('button', { name: 'Close' }); + expect(closeButtons).toHaveLength(2); + await user.click(closeButtons[1]); + expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false); + + await user.click(closeButtons[0]); + expect(admin.setShowUpdateModal).toHaveBeenCalledTimes(2); + }); + + it('FE-MOB-ASHEET-017: rotating the JWT secret logs the admin out and redirects', async () => { + const user = userEvent.setup(); + let rotated = false; + server.use( + http.post('/api/admin/rotate-jwt-secret', () => { + rotated = true; + return HttpResponse.json({ success: true }); + }), + ); + const admin = renderSheets({ showRotateJwtModal: true }); + + expect(screen.getByRole('dialog', { name: 'Rotate JWT Secret' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Rotate & Log out' })); + + await waitFor(() => expect(admin.logout).toHaveBeenCalled()); + expect(rotated).toBe(true); + expect(admin.setRotatingJwt).toHaveBeenCalledWith(true); + expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false); + expect(admin.navigate).toHaveBeenCalledWith('/login', { state: { noRedirect: true } }); + }); + + it('FE-MOB-ASHEET-018: a failing rotation toasts and leaves the session alone', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/admin/rotate-jwt-secret', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + const admin = renderSheets({ showRotateJwtModal: true }); + + await user.click(screen.getByRole('button', { name: 'Rotate & Log out' })); + + await waitFor(() => expect(admin.toast.error).toHaveBeenCalledWith('Error')); + expect(admin.logout).not.toHaveBeenCalled(); + expect(admin.setRotatingJwt).toHaveBeenLastCalledWith(false); + }); + + it('FE-MOB-ASHEET-019: Escape and the header X close the rotate sheet', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ showRotateJwtModal: true }); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(admin.setShowRotateJwtModal).toHaveBeenCalledWith(false); + + await user.click(screen.getByRole('button', { name: 'Close' })); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(admin.setShowRotateJwtModal).toHaveBeenCalledTimes(3); + }); + + it('FE-MOB-ASHEET-020: Escape and the header X close the create sheet', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ showCreateUser: true }); + + fireEvent.keyDown(document, { key: 'Escape' }); + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(admin.setShowCreateUser).toHaveBeenCalledTimes(2); + expect(admin.setShowCreateUser).toHaveBeenCalledWith(false); + }); + + it('FE-MOB-ASHEET-021: Escape and the header X close the edit sheet', async () => { + const user = userEvent.setup(); + const admin = renderSheets({ editingUser: ALICE }); + + fireEvent.keyDown(document, { key: 'Escape' }); + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(admin.setEditingUser).toHaveBeenCalledTimes(2); + expect(admin.setEditingUser).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-ASHEET-022: Escape closes the update sheet', () => { + const admin = renderSheets({ + showUpdateModal: true, + updateInfo: { update_available: true, current: '3.4.0', latest: '3.5.0', is_docker: true }, + }); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(admin.setShowUpdateModal).toHaveBeenCalledWith(false); + }); + + it('FE-MOB-ASHEET-023: the passkey sheet closes from Escape and from its header X', async () => { + const user = userEvent.setup(); + renderSheets({ editingUser: ALICE }); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + fireEvent.keyDown(document, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(), + ); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + // Second dialog in the DOM is the confirm on top of the edit sheet + await user.click(screen.getAllByRole('button', { name: 'Close' })[1]); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Reset passkeys' })).not.toBeInTheDocument(), + ); + }); + + it('FE-MOB-ASHEET-024: confirming the passkey reset without an edited user does nothing', async () => { + const user = userEvent.setup(); + let called = false; + server.use( + http.delete('/api/admin/users/:id/passkeys', () => { + called = true; + return HttpResponse.json({ deleted: 0 }); + }), + ); + const admin = buildAdminHook({ editingUser: ALICE }); + const { rerender } = render(); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + // The edit sheet closes underneath while the confirm stays open + rerender(); + + await user.click(screen.getByRole('button', { name: 'Reset passkeys' })); + + expect(called).toBe(false); + expect(screen.getByRole('dialog', { name: 'Reset passkeys' })).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/admin/MAdminUsersSection.test.tsx b/client/tests/unit/mobile/admin/MAdminUsersSection.test.tsx new file mode 100644 index 000000000..e13f52daf --- /dev/null +++ b/client/tests/unit/mobile/admin/MAdminUsersSection.test.tsx @@ -0,0 +1,250 @@ +// FE-MOB-AUSERS-001 to FE-MOB-AUSERS-016 +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; +import { buildAdminHook, buildAdminUser, type AdminHook } from '../../../helpers/mobileAdmin'; +import { useTranslation } from '../../../../src/i18n'; +import MAdminUsersSection from '../../../../src/mobile/screens/admin/MAdminUsersSection'; + +// The permissions matrix self-loads and has its own suite — stub it out here. +vi.mock('../../../../src/mobile/screens/admin/MAdminPermissionsPanel', () => ({ + default: () =>
, +})); + +function Harness({ admin }: { admin: AdminHook }) { + const { t, locale } = useTranslation(); + return ; +} + +function renderSection(overrides: Record = {}) { + const admin = buildAdminHook(overrides); + render(); + return admin as unknown as Record>; +} + +interface InviteForm { + max_uses: number; + expires_in_days: number | ''; + trip_id: number | ''; +} + +/** + * The invite form controls read e.target.value inside the state updater, so the + * spy fixture can't be inspected after the fact — the sheet needs real state to + * show what a chip or the trip select actually writes. + */ +function InviteFormHarness({ overrides }: { overrides: Record }) { + const { t, locale } = useTranslation(); + const [inviteForm, setInviteForm] = React.useState({ + max_uses: 1, + expires_in_days: 7, + trip_id: '', + }); + const admin = buildAdminHook({ showCreateInvite: true, ...overrides, inviteForm, setInviteForm }); + return ; +} + +function isActiveChip(el: HTMLElement): boolean { + return el.className.includes('bg-m-act'); +} + +const ME = buildAdminUser({ id: 1, username: 'admin', email: 'admin@example.com', role: 'admin' }); +const ALICE = buildAdminUser({ id: 2, username: 'alice', email: 'alice@example.com', role: 'user' }); + +function buildInvite(overrides: Record = {}) { + return { + id: 1, + token: 'abcdefghijklmnop', + max_uses: 3, + used_count: 1, + expires_at: null, + trip_title: null, + created_by_name: 'admin', + ...overrides, + }; +} + +beforeEach(() => { + resetAllStores(); +}); + +describe('MAdminUsersSection', () => { + it('FE-MOB-AUSERS-001: lists users with role badges, the "you" marker and the count', () => { + renderSection({ users: [ME, ALICE], currentUser: ME }); + + expect(screen.getByText('2 users')).toBeInTheDocument(); + expect(screen.getByText('admin')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + expect(screen.getByText('Administrator')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByText('(You)')).toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-002: users without an avatar get an initial, users with one get the image', () => { + renderSection({ users: [ALICE, buildAdminUser({ id: 3, username: 'bob', avatar_url: '/uploads/bob.png' })] }); + + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByRole('presentation')).toHaveAttribute('src', '/uploads/bob.png'); + }); + + it('FE-MOB-AUSERS-003: the spinner replaces the list while users are loading', () => { + renderSection({ users: [ALICE], isLoading: true }); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('alice')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-004: tapping a row opens the edit sheet for that user', async () => { + const user = userEvent.setup(); + const admin = renderSection({ users: [ME, ALICE], currentUser: ME }); + + await user.click(screen.getByText('alice')); + + expect(admin.handleEditUser).toHaveBeenCalledWith(ALICE); + }); + + it('FE-MOB-AUSERS-005: the New user button opens the create sheet', async () => { + const user = userEvent.setup(); + const admin = renderSection(); + + await user.click(screen.getByRole('button', { name: 'New user' })); + + expect(admin.setShowCreateUser).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-AUSERS-006: the invite card shows its empty state without invites', () => { + renderSection(); + + expect(screen.getByText('Invite Links')).toBeInTheDocument(); + expect(screen.getByText('No invite links created yet')).toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-007: an active invite shows the truncated token, usage and copy action', () => { + renderSection({ invites: [buildInvite()] }); + + expect(screen.getByText('abcdefghijkl…')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText(/1\/3 used/)).toBeInTheDocument(); + expect(screen.getByText(/by admin/)).toBeInTheDocument(); + expect(screen.getByTitle('Copy link')).toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-008: unlimited uses render as ∞ and an expiry/trip line is appended', () => { + renderSection({ + invites: [ + buildInvite({ max_uses: 0, used_count: 4, expires_at: '2099-01-02T00:00:00Z', trip_title: 'Japan' }), + ], + }); + + expect(screen.getByText(/4\/∞ used/)).toBeInTheDocument(); + expect(screen.getByText(/expires 1\/2\/2099/)).toBeInTheDocument(); + expect(screen.getByText(/adds to Japan/)).toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-009: expired and used-up invites lose the copy action', () => { + renderSection({ + invites: [ + buildInvite({ id: 1, token: 'expired-token-xx', expires_at: '2020-01-01T00:00:00Z' }), + buildInvite({ id: 2, token: 'usedup-token-xxx', max_uses: 2, used_count: 2 }), + ], + }); + + expect(screen.getByText('Expired')).toBeInTheDocument(); + expect(screen.getByText('Used up')).toBeInTheDocument(); + expect(screen.queryByTitle('Copy link')).not.toBeInTheDocument(); + expect(screen.getAllByTitle('Delete')).toHaveLength(2); + }); + + it('FE-MOB-AUSERS-010: copy and delete hit the handlers with token and id', async () => { + const user = userEvent.setup(); + const admin = renderSection({ invites: [buildInvite({ id: 7, token: 'abcdefghijklmnop' })] }); + + await user.click(screen.getByTitle('Copy link')); + expect(admin.copyInviteLink).toHaveBeenCalledWith('abcdefghijklmnop'); + + await user.click(screen.getByTitle('Delete')); + expect(admin.handleDeleteInvite).toHaveBeenCalledWith(7); + }); + + it('FE-MOB-AUSERS-011: the create-invite sheet is closed until showCreateInvite flips', async () => { + const user = userEvent.setup(); + const admin = renderSection(); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Create Link' })); + expect(admin.setShowCreateInvite).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-AUSERS-012: the open sheet marks the active max-uses and expiry chips', () => { + renderSection({ showCreateInvite: true, inviteForm: { max_uses: 3, expires_in_days: '', trip_id: '' } }); + + expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '3×' }).className).toContain('bg-m-act'); + expect(screen.getByRole('button', { name: '1×' }).className).not.toContain('bg-m-act'); + // Both chip rows end in ∞ — max uses (unlimited) and expiry (never) + const [unlimitedUses, neverExpires] = screen.getAllByRole('button', { name: '∞' }); + expect(unlimitedUses.className).not.toContain('bg-m-act'); + expect(neverExpires.className).toContain('bg-m-act'); + }); + + it('FE-MOB-AUSERS-013: chips patch only their own field of the invite form', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: '5×' })); + expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true); + expect(isActiveChip(screen.getByRole('button', { name: '1×' }))).toBe(false); + // The expiry row is untouched: 7d is still the active chip + expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(true); + + await user.click(screen.getByRole('button', { name: '14d' })); + expect(isActiveChip(screen.getByRole('button', { name: '14d' }))).toBe(true); + expect(isActiveChip(screen.getByRole('button', { name: '7d' }))).toBe(false); + expect(isActiveChip(screen.getByRole('button', { name: '5×' }))).toBe(true); + }); + + it('FE-MOB-AUSERS-014: the trip select is hidden without trips', () => { + renderSection({ showCreateInvite: true }); + + expect(screen.getByRole('dialog', { name: 'Create Link' })).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect(screen.queryByText('Add to trip (optional)')).not.toBeInTheDocument(); + }); + + it('FE-MOB-AUSERS-015: the trip select stores the numeric id and maps "No trip" back to ""', async () => { + const user = userEvent.setup(); + const handleCreateInvite = vi.fn(); + render( + , + ); + + const select = screen.getByRole('combobox') as HTMLSelectElement; + expect(screen.getByRole('option', { name: 'No trip' })).toBeInTheDocument(); + expect(select.value).toBe(''); + + fireEvent.change(select, { target: { value: '4' } }); + expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe('4'); + + fireEvent.change(select, { target: { value: '' } }); + expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(''); + + await user.click(screen.getByRole('button', { name: 'Create & Copy' })); + expect(handleCreateInvite).toHaveBeenCalled(); + }); + + it('FE-MOB-AUSERS-016: cancel, close and Escape all dismiss the sheet', async () => { + const user = userEvent.setup(); + const admin = renderSection({ showCreateInvite: true }); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(admin.setShowCreateInvite).toHaveBeenCalledWith(false); + + await user.click(screen.getByRole('button', { name: 'Close' })); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(admin.setShowCreateInvite).toHaveBeenCalledTimes(3); + }); +}); diff --git a/client/tests/unit/mobile/atlas/MAtlas.test.tsx b/client/tests/unit/mobile/atlas/MAtlas.test.tsx new file mode 100644 index 000000000..95a8e755f --- /dev/null +++ b/client/tests/unit/mobile/atlas/MAtlas.test.tsx @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../../helpers/render'; +import { buildAtlasController, buildAtlasData, buildBucketItem, buildCountryDetail } from '../../../helpers/atlas'; +import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlasController'; +import type { CountryDetail } from '../../../../src/pages/atlas/atlasModel'; +import MAtlas from '../../../../src/mobile/screens/atlas/MAtlas'; + +// FE-MOB-ATLASSCR-001 to FE-MOB-ATLASSCR-016 + +const mocks = vi.hoisted(() => ({ atlas: {} as AtlasController })); + +vi.mock('../../../../src/pages/atlas/useAtlas', () => ({ + useAtlas: () => mocks.atlas, +})); + +function setAtlas(over: Record = {}): AtlasController { + mocks.atlas = buildAtlasController(over); + return mocks.atlas; +} + +beforeEach(() => { + setAtlas(); +}); + +describe('MAtlas', () => { + it('FE-MOB-ATLASSCR-001: shows only a spinner while the atlas is loading', () => { + setAtlas({ loading: true }); + render(); + + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('atlas.bucketTab')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-002: renders the map container, the bucket header and the stats card', () => { + setAtlas({ + data: buildAtlasData({ stats: { totalTrips: 4, totalPlaces: 21, totalCountries: 7, totalDays: 30, totalCities: 9 } }), + }); + render(); + + expect(document.querySelector('.animate-spin')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'atlas.bucketTab' })).toBeInTheDocument(); + // Stats card labels come from the real translations via useTranslation. + expect(screen.getByText('Countries')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('21')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-003: the header button opens the bucket-list sheet', () => { + setAtlas({ bucketList: [buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP' })] }); + render(); + + expect(screen.queryByText('Kyoto')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'atlas.bucketTab' })); + + expect(screen.getByText('Kyoto')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-004: the ?search=1 hand-off opens the search overlay and clears the param', async () => { + setAtlas({ atlas_country_options: [{ code: 'JP', label: 'Japan' }] }); + render(, { initialEntries: ['/atlas?search=1'] }); + + const input = await screen.findByPlaceholderText('Search a country...'); + expect(input).toBeInTheDocument(); + + // The param is consumed once — closing and reopening is up to the user again. + fireEvent.click(screen.getByText('Cancel')); + await waitFor(() => expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-005: no search param leaves the overlay closed', () => { + render(); + expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-006: picking a country from the search flies the map there and closes the overlay', async () => { + const atlas = setAtlas({ + atlas_country_options: [{ code: 'JP', label: 'Japan' }, { code: 'FR', label: 'France' }], + }); + render(, { initialEntries: ['/atlas?search=1'] }); + + const input = await screen.findByPlaceholderText('Search a country...'); + fireEvent.change(input, { target: { value: 'jap' } }); + fireEvent.click(screen.getByText('Japan')); + + expect(atlas.select_country_from_search).toHaveBeenCalledWith('JP'); + await waitFor(() => expect(screen.queryByPlaceholderText('Search a country...')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-007: suggestions list visited countries newest first, then bucket countries', async () => { + setAtlas({ + data: buildAtlasData({ + countries: [ + { code: 'FR', tripCount: 1, placeCount: 1, lastVisit: '2022-01-01' }, + { code: 'JP', tripCount: 1, placeCount: 1, lastVisit: '2025-08-01' }, + ], + }), + bucketList: [ + buildBucketItem({ id: 1, name: 'Lima', country_code: 'PE' }), + buildBucketItem({ id: 2, name: 'Kyoto', country_code: 'JP' }), + buildBucketItem({ id: 3, name: 'Andes', country_code: null }), + buildBucketItem({ id: 4, name: 'Machu Picchu', country_code: 'PER' }), + ], + }); + render(, { initialEntries: ['/atlas?search=1'] }); + + await screen.findByPlaceholderText('Search a country...'); + const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img')); + expect(rows.map((r) => r.textContent)).toEqual(['JapanVisited', 'FranceVisited', 'PeruBucket List']); + }); + + it('FE-MOB-ATLASSCR-008: suggestions are capped at five entries', async () => { + setAtlas({ + data: buildAtlasData({ + countries: ['FR', 'JP', 'IT', 'ES', 'DE', 'US'].map((code, i) => ({ + code, tripCount: 1, placeCount: 1, lastVisit: `202${i}-01-01`, + })), + }), + }); + render(, { initialEntries: ['/atlas?search=1'] }); + + await screen.findByPlaceholderText('Search a country...'); + expect(screen.getAllByRole('button').filter((b) => b.querySelector('img'))).toHaveLength(5); + }); + + it('FE-MOB-ATLASSCR-009: loading a country detail opens the detail sheet with its counts', async () => { + setAtlas({ + selectedCountry: 'JP', + countryDetail: buildCountryDetail({ + places: [{ id: 1 }, { id: 2 }] as unknown as CountryDetail['places'], + trips: [{ id: 11, title: 'Kansai' }, { id: 12, title: 'Hokkaido' }], + }), + }); + render(); + + expect(await screen.findByText('Japan')).toBeInTheDocument(); + expect(screen.getByText('2 atlas.places · 2 atlas.trips')).toBeInTheDocument(); + expect(screen.getByText('Kansai')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-010: a trip chip navigates to the trip planner', async () => { + const atlas = setAtlas({ + selectedCountry: 'JP', + countryDetail: buildCountryDetail({ trips: [{ id: 11, title: 'Kansai' }] }), + }); + render(); + + fireEvent.click(await screen.findByText('Kansai')); + + expect(atlas.navigate).toHaveBeenCalledWith('/trips/11'); + }); + + it('FE-MOB-ATLASSCR-011: a manually marked country offers the remove action and closes the sheet', async () => { + const atlas = setAtlas({ + selectedCountry: 'JP', + countryDetail: buildCountryDetail({ manually_marked: true }), + }); + render(); + + fireEvent.click(await screen.findByText('atlas.unmark')); + + expect(atlas.handleUnmarkCountry).toHaveBeenCalledWith('JP'); + await waitFor(() => expect(screen.queryByText('atlas.unmark')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-012: a country derived from real trips has no remove action', async () => { + setAtlas({ + selectedCountry: 'JP', + countryDetail: buildCountryDetail({ trips: [{ id: 11, title: 'Kansai' }] }), + }); + render(); + + await screen.findByText('Japan'); + expect(screen.queryByText('atlas.unmark')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASSCR-013: the detail sheet closes via its close button', async () => { + setAtlas({ selectedCountry: 'JP', countryDetail: buildCountryDetail() }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: 'common.close' })); + + await waitFor(() => expect(screen.queryByText('Japan')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-014: the detail sheet also closes on Escape', async () => { + setAtlas({ selectedCountry: 'JP', countryDetail: buildCountryDetail() }); + render(); + + await screen.findByText('Japan'); + fireEvent.keyDown(document, { key: 'Escape' }); + + await waitFor(() => expect(screen.queryByText('Japan')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-015: the bucket sheet closes again from its own close button', async () => { + setAtlas({ bucketList: [buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP' })] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'atlas.bucketTab' })); + expect(screen.getByText('Kyoto')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'common.close' })); + + await waitFor(() => expect(screen.queryByText('Kyoto')).not.toBeInTheDocument()); + }); + + it('FE-MOB-ATLASSCR-016: duplicate and never-visited countries collapse into one suggestion each', async () => { + setAtlas({ + data: buildAtlasData({ + countries: [ + { code: 'FR', tripCount: 1, placeCount: 1 }, + { code: 'FR', tripCount: 2, placeCount: 3 }, + { code: 'JP', tripCount: 1, placeCount: 1 }, + ], + }), + }); + render(, { initialEntries: ['/atlas?search=1'] }); + + await screen.findByPlaceholderText('Search a country...'); + const rows = screen.getAllByRole('button').filter((b) => b.querySelector('img')); + expect(rows.map((r) => r.textContent)).toEqual(['FranceVisited', 'JapanVisited']); + }); +}); diff --git a/client/tests/unit/mobile/atlas/MAtlasBucketSheet.test.tsx b/client/tests/unit/mobile/atlas/MAtlasBucketSheet.test.tsx new file mode 100644 index 000000000..19a5fb654 --- /dev/null +++ b/client/tests/unit/mobile/atlas/MAtlasBucketSheet.test.tsx @@ -0,0 +1,229 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '../../../helpers/render'; +import { buildAtlasController, buildBucketItem } from '../../../helpers/atlas'; +import MAtlasBucketSheet from '../../../../src/mobile/screens/atlas/MAtlasBucketSheet'; + +// FE-MOB-ATLASBUCK-001 to FE-MOB-ATLASBUCK-021 + +function renderSheet(over: Record = {}, props: { open?: boolean; onClose?: () => void } = {}) { + const atlas = buildAtlasController(over); + const onClose = props.onClose ?? vi.fn(() => undefined); + render(); + return { atlas, onClose }; +} + +const emptyForm = { name: '', notes: '', lat: '', lng: '', target_date: '' }; + +describe('MAtlasBucketSheet', () => { + it('FE-MOB-ATLASBUCK-001: stays unmounted while closed', () => { + renderSheet({}, { open: false }); + expect(screen.queryByText('atlas.bucketTab')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-002: an empty list shows the empty state and no counter', () => { + renderSheet({ bucketList: [] }); + + expect(screen.getByText('atlas.bucketEmpty')).toBeInTheDocument(); + expect(screen.getByText('atlas.bucketEmptyHint')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-003: items render with country, target month, coordinates and notes', () => { + renderSheet({ + bucketList: [ + buildBucketItem({ id: 1, name: 'Kyoto', country_code: 'JP', target_date: '2027-04', lat: 35.01, lng: 135.77, notes: 'cherry blossom' }), + ], + }); + + expect(screen.getByText('Kyoto')).toBeInTheDocument(); + expect(screen.getByText('Japan · Apr 2027 · 35.01, 135.77 · cherry blossom')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-004: an alpha-3 country code is resolved back to its flag and name', () => { + renderSheet({ bucketList: [buildBucketItem({ id: 2, name: 'Porto', country_code: 'PRT' })] }); + + const flag = document.querySelector('img') as HTMLImageElement | null; + expect(flag?.src).toContain('flagcdn.com/w40/pt.png'); + expect(screen.getByText('Portugal')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-005: an item without a country shows the star placeholder and only its name', () => { + renderSheet({ bucketList: [buildBucketItem({ id: 3, name: 'Northern lights', country_code: null })] }); + + expect(document.querySelector('img')).toBeNull(); + expect(screen.getByText('Northern lights')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-006: a year-only target date renders unchanged', () => { + renderSheet({ bucketList: [buildBucketItem({ id: 4, name: 'Patagonia', country_code: null, target_date: '2030' })] }); + expect(screen.getByText('2030')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-007: the trash button deletes the item by id', () => { + const { atlas } = renderSheet({ bucketList: [buildBucketItem({ id: 9, name: 'Kyoto' })] }); + + fireEvent.click(screen.getByRole('button', { name: 'atlas.unmark' })); + + expect(atlas.handleDeleteBucketItem).toHaveBeenCalledWith(9); + }); + + it('FE-MOB-ATLASBUCK-008: the add button opens the form', () => { + const { atlas } = renderSheet({ showBucketAdd: false }); + + fireEvent.click(screen.getByText('atlas.addPoi')); + + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-ATLASBUCK-009: typing without a picked place feeds the POI search field', () => { + const { atlas } = renderSheet({ showBucketAdd: true }); + + fireEvent.change(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { target: { value: 'Kyo' } }); + + expect(atlas.setBucketSearch).toHaveBeenCalledWith('Kyo'); + expect(atlas.setBucketForm).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ATLASBUCK-010: typing after a place was picked edits the name instead', () => { + const { atlas } = renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, name: 'Kyoto' } }); + + fireEvent.change(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { target: { value: 'Kyoto old town' } }); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, name: 'Kyoto old town' }); + }); + + it('FE-MOB-ATLASBUCK-011: Enter searches while the name is empty and adds once it is set', () => { + const search = renderSheet({ showBucketAdd: true }); + fireEvent.keyDown(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { key: 'Enter' }); + expect(search.atlas.handleBucketPoiSearch).toHaveBeenCalled(); + expect(search.atlas.handleAddBucketItem).not.toHaveBeenCalled(); + + fireEvent.keyDown(screen.getByPlaceholderText('atlas.bucketNamePlaceholder'), { key: 'a' }); + expect(search.atlas.handleBucketPoiSearch).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-ATLASBUCK-012: the search button is disabled while a lookup is running', () => { + const { atlas } = renderSheet({ showBucketAdd: true, bucketSearching: true }); + + const searchBtn = screen.getByRole('button', { name: 'common.search' }); + expect(searchBtn).toBeDisabled(); + + fireEvent.click(searchBtn); + expect(atlas.handleBucketPoiSearch).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ATLASBUCK-013: picking a search result keeps notes and target month but fills name and coordinates', () => { + const { atlas } = renderSheet({ + showBucketAdd: true, + bucketForm: { name: '', notes: 'onsen', lat: '', lng: '', target_date: '2027-04' }, + bucketSearch: 'kyo', + bucketSearchResults: [{ name: 'Kyoto', lat: 35.0116, lng: 135.7681, address: 'Japan' }], + }); + + expect(screen.getByText('Japan')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Kyoto')); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ + name: 'Kyoto', notes: 'onsen', lat: '35.0116', lng: '135.7681', target_date: '2027-04', + }); + expect(atlas.setBucketSearchResults).toHaveBeenCalledWith([]); + expect(atlas.setBucketSearch).toHaveBeenCalledWith(''); + }); + + it('FE-MOB-ATLASBUCK-014: a result without a name falls back to the typed query', () => { + const { atlas } = renderSheet({ + showBucketAdd: true, + bucketSearch: 'somewhere', + bucketSearchResults: [{ address: 'Nowhere' }], + }); + + fireEvent.click(screen.getByText('Nowhere')); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, name: 'somewhere', lat: '', lng: '' }); + }); + + it('FE-MOB-ATLASBUCK-015: the clear button drops the picked place but keeps the form open', () => { + const { atlas } = renderSheet({ + showBucketAdd: true, + bucketForm: { name: 'Kyoto', notes: 'onsen', lat: '35.01', lng: '135.77', target_date: '' }, + }); + + expect(screen.getByText('35.0100, 135.7700')).toBeInTheDocument(); + // The clear icon button sits in the name row, ahead of the form's cancel button. + fireEvent.click(screen.getAllByRole('button', { name: 'common.cancel' })[0]); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ name: '', notes: 'onsen', lat: '', lng: '', target_date: '' }); + expect(atlas.setShowBucketAdd).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ATLASBUCK-016: notes feed the form and Add stays disabled until a name exists', () => { + const { atlas } = renderSheet({ showBucketAdd: true }); + + fireEvent.change(screen.getByPlaceholderText('atlas.bucketNotesPlaceholder'), { target: { value: 'diving' } }); + expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, notes: 'diving' }); + + expect(screen.getByText('common.add')).toBeDisabled(); + }); + + it('FE-MOB-ATLASBUCK-017: cancelling the form resets name, search and results', () => { + const { atlas } = renderSheet({ + showBucketAdd: true, + bucketForm: { name: 'Kyoto', notes: 'onsen', lat: '', lng: '', target_date: '2027-04' }, + }); + + fireEvent.click(screen.getByText('common.cancel')); + + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false); + expect(atlas.setBucketForm).toHaveBeenCalledWith(emptyForm); + expect(atlas.setBucketSearch).toHaveBeenCalledWith(''); + expect(atlas.setBucketSearchResults).toHaveBeenCalledWith([]); + }); + + it('FE-MOB-ATLASBUCK-018: closing the sheet resets the form as well', () => { + const onClose = vi.fn(() => undefined); + const { atlas } = renderSheet({ showBucketAdd: true }, { onClose }); + + fireEvent.click(screen.getByRole('button', { name: 'common.close' })); + + expect(atlas.setShowBucketAdd).toHaveBeenCalledWith(false); + expect(onClose).toHaveBeenCalled(); + }); + + describe('month/year picker', () => { + it('FE-MOB-ATLASBUCK-019: opens on the current year and writes the picked month back', () => { + const { atlas } = renderSheet({ showBucketAdd: true }); + const year = new Date().getFullYear(); + + fireEvent.click(screen.getByText('atlas.bucketWhen')); + expect(screen.getByText(String(year))).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Mar')); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, target_date: `${year}-03` }); + expect(screen.queryByText('Mar')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-020: the year steppers move the grid a year at a time', () => { + renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, target_date: '2027-04' } }); + + expect(screen.getByText('April 2027')).toBeInTheDocument(); + fireEvent.click(screen.getByText('April 2027')); + + fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.prevYear' })); + expect(screen.getByText('2026')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.nextYear' })); + fireEvent.click(screen.getByRole('button', { name: 'mobileVacay.nextYear' })); + expect(screen.getByText('2028')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASBUCK-021: reset clears an already chosen target month', () => { + const { atlas } = renderSheet({ showBucketAdd: true, bucketForm: { ...emptyForm, target_date: '2027-04' } }); + + fireEvent.click(screen.getByText('April 2027')); + fireEvent.click(screen.getByText('common.reset')); + + expect(atlas.setBucketForm).toHaveBeenCalledWith({ ...emptyForm, target_date: '' }); + }); + }); +}); diff --git a/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx b/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx new file mode 100644 index 000000000..08cb6eda1 --- /dev/null +++ b/client/tests/unit/mobile/atlas/MAtlasCountryPopup.test.tsx @@ -0,0 +1,353 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor, fireEvent } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { buildAtlasController, buildAtlasData, type VisitedRegions } from '../../../helpers/atlas'; +import MAtlasCountryPopup from '../../../../src/mobile/screens/atlas/MAtlasCountryPopup'; +import type { AtlasController } from '../../../../src/mobile/screens/atlas/atlasController'; +import type { AtlasData } from '../../../../src/pages/atlas/atlasModel'; + +// FE-MOB-ATLASPOP-001 to FE-MOB-ATLASPOP-025 + +type ConfirmAction = AtlasController['confirmAction']; + +const toast = vi.fn(() => 0); + +function renderPopup(over: Record = {}) { + const atlas = buildAtlasController(over); + const view = render(); + return { atlas, view }; +} + +function chooseAction(over: Partial> = {}): ConfirmAction { + return { type: 'choose', code: 'DE', name: 'Germany', ...over } as ConfirmAction; +} + +beforeEach(() => { + toast.mockClear(); + window.__addToast = toast; + 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: { id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' } }), + ), + ); +}); + +describe('MAtlasCountryPopup', () => { + it('FE-MOB-ATLASPOP-001: renders nothing while there is no pending action', () => { + renderPopup({ confirmAction: null }); + expect(screen.queryByText('atlas.markVisited')).not.toBeInTheDocument(); + }); + + it('FE-MOB-ATLASPOP-002: a country choice shows the flag image and both option rows', () => { + renderPopup({ confirmAction: chooseAction() }); + + const flag = document.querySelector('img') as HTMLImageElement | null; + expect(flag?.src).toContain('flagcdn.com/w80/de.png'); + expect(screen.getByText('Germany')).toBeInTheDocument(); + expect(screen.getByText('atlas.markVisited')).toBeInTheDocument(); + expect(screen.getByText('atlas.markVisitedHint')).toBeInTheDocument(); + expect(screen.getByText('atlas.addToBucket')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASPOP-003: a non-ISO2 code falls back to the flag glyph instead of an image', () => { + renderPopup({ confirmAction: chooseAction({ code: 'DEU', name: 'Germany' }) }); + expect(document.querySelector('img')).toBeNull(); + }); + + it('FE-MOB-ATLASPOP-004: marking a country appends it to the atlas data and closes the sheet', async () => { + const { atlas } = renderPopup({ confirmAction: chooseAction(), data: buildAtlasData() }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + 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-MOB-ATLASPOP-005: marking a country already in the list leaves the data untouched', async () => { + const { atlas } = renderPopup({ confirmAction: chooseAction({ code: 'FR', name: 'France' }) }); + const before = atlas.data; + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(atlas.data).toBe(before); + }); + + it('FE-MOB-ATLASPOP-006: a failing mark request surfaces a toast and still closes', async () => { + server.use( + http.post('/api/addons/atlas/country/:code/mark', () => + HttpResponse.json({ error: 'Country locked' }, { status: 400 }), + ), + ); + const { atlas } = renderPopup({ confirmAction: chooseAction() }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Country locked', 'error', undefined)); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-ATLASPOP-007: the bucket option switches the pending action to the bucket step', () => { + const action = chooseAction(); + const { atlas } = renderPopup({ confirmAction: action }); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' }); + }); + + it('FE-MOB-ATLASPOP-008: a region choice shows the parent country and the region hint', () => { + renderPopup({ + confirmAction: { type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' }, + }); + + expect(screen.getByText('Bretagne')).toBeInTheDocument(); + expect(screen.getByText('France')).toBeInTheDocument(); + expect(screen.getByText('atlas.markRegionVisitedHint')).toBeInTheDocument(); + }); + + it('FE-MOB-ATLASPOP-009: marking a region records it and adds the country when it is new', async () => { + const { atlas } = renderPopup({ + confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA', countryName: 'Spain' }, + }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + const regions = atlas.visitedRegions as VisitedRegions; + expect(regions.ES).toEqual([{ code: 'ES-GA', name: 'Galicia', placeCount: 0, manuallyMarked: true }]); + const data = atlas.data as AtlasData; + expect(data.countries.map((c) => c.code)).toContain('ES'); + expect(data.stats.totalCountries).toBe(2); + }); + + it('FE-MOB-ATLASPOP-010: marking an already-recorded region keeps the region list as is', async () => { + const existing: VisitedRegions = { FR: [{ code: 'FR-BRE', name: 'Bretagne', placeCount: 2 }] }; + const { atlas } = renderPopup({ + confirmAction: { type: 'choose-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' }, + visitedRegions: existing, + }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(atlas.visitedRegions).toBe(existing); + }); + + it('FE-MOB-ATLASPOP-011: a failing region mark reports the error', async () => { + server.use( + http.post('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Bad region' }, { status: 422 })), + ); + renderPopup({ + confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA', countryName: 'Spain' }, + }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Bad region', 'error', undefined)); + }); + + it('FE-MOB-ATLASPOP-012: removing the last region of an unvisited-otherwise country drops the country too', async () => { + const { atlas } = renderPopup({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62', countryName: 'Italy' }, + visitedRegions: { IT: [{ code: 'IT-62', name: 'Lazio', placeCount: 0, manuallyMarked: true }] } 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 }, + }), + }); + + expect(screen.getByText('atlas.confirmUnmarkRegion')).toBeInTheDocument(); + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(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-MOB-ATLASPOP-013: a country with real places survives removing one of its regions', async () => { + const { atlas } = renderPopup({ + confirmAction: { type: 'unmark-region', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE', countryName: 'France' }, + visitedRegions: { + FR: [ + { code: 'FR-BRE', name: 'Bretagne', placeCount: 1 }, + { code: 'FR-IDF', name: 'Ile-de-France', placeCount: 4 }, + ], + } as VisitedRegions, + }); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + const regions = atlas.visitedRegions as VisitedRegions; + expect(regions.FR.map((r) => r.code)).toEqual(['FR-IDF']); + expect((atlas.data as AtlasData).countries.map((c) => c.code)).toEqual(['FR']); + }); + + it('FE-MOB-ATLASPOP-014: a failing region removal reports the error', async () => { + server.use( + http.delete('/api/addons/atlas/region/:code/mark', () => HttpResponse.json({ error: 'Nope' }, { status: 500 })), + ); + renderPopup({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62', countryName: 'Italy' }, + }); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Nope', 'error', undefined)); + }); + + it('FE-MOB-ATLASPOP-015: the bucket step posts the chosen target month and prepends the item', async () => { + let body: unknown = null; + server.use( + http.post('/api/addons/atlas/bucket-list', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ item: { id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' } }); + }), + ); + const { atlas } = renderPopup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + + const monthInput = document.querySelector('input[type="month"]') as HTMLInputElement; + fireEvent.change(monthInput, { target: { value: '2027-05' } }); + expect(monthInput.value).toBe('2027-05'); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(body).toEqual({ name: 'Germany', country_code: 'DE', target_date: '2027-05' }); + expect(atlas.bucketList).toEqual([ + { id: 42, name: 'Germany', country_code: 'DE', lat: null, lng: null, notes: null, target_date: '2027-05' }, + ]); + }); + + it('FE-MOB-ATLASPOP-016: back from the bucket step returns to the region choice when a region is pending', () => { + const action = { type: 'bucket', code: 'FR', name: 'Bretagne', regionCode: 'FR-BRE' } as ConfirmAction; + const { atlas } = renderPopup({ confirmAction: action }); + + fireEvent.click(screen.getByText('common.back')); + + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'choose-region' }); + }); + + it('FE-MOB-ATLASPOP-017: a failing bucket post reports the error', async () => { + server.use( + http.post('/api/addons/atlas/bucket-list', () => HttpResponse.json({ error: 'Full' }, { status: 400 })), + ); + renderPopup({ confirmAction: { type: 'bucket', code: 'DE', name: 'Germany' } }); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Full', 'error', undefined)); + }); + + it('FE-MOB-ATLASPOP-018: mark and unmark confirmations delegate to the shared executor', () => { + const { atlas } = renderPopup({ confirmAction: { type: 'mark', code: 'DE', name: 'Germany' } }); + + expect(screen.getByText('atlas.confirmMark')).toBeInTheDocument(); + fireEvent.click(screen.getByText('atlas.markVisited')); + expect(atlas.executeConfirmAction).toHaveBeenCalled(); + + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-ATLASPOP-019: the unmark confirmation offers cancel and the destructive confirm', () => { + const { atlas } = renderPopup({ confirmAction: { type: 'unmark', code: 'FR', name: 'France' } }); + + 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-MOB-ATLASPOP-020: the region removal confirmation can be cancelled', () => { + const { atlas } = renderPopup({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' }, + }); + + fireEvent.click(screen.getByText('common.cancel')); + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-ATLASPOP-021: the region choice can be moved to the bucket step', () => { + const action = { type: 'choose-region', code: 'ES', name: 'Galicia', regionCode: 'ES-GA' } as ConfirmAction; + const { atlas } = renderPopup({ confirmAction: action }); + + fireEvent.click(screen.getByText('atlas.addToBucket')); + + expect(atlas.setConfirmAction).toHaveBeenCalledWith({ ...action, type: 'bucket' }); + }); + + it('FE-MOB-ATLASPOP-022: a region action without a region code performs no request', async () => { + const markSpy = vi.fn(); + const deleteSpy = vi.fn(); + server.use( + http.post('/api/addons/atlas/region/:code/mark', () => { markSpy(); return HttpResponse.json({}); }), + http.delete('/api/addons/atlas/region/:code/mark', () => { deleteSpy(); return HttpResponse.json({}); }), + ); + const choose = renderPopup({ confirmAction: { type: 'choose-region', code: 'ES', name: 'Galicia' } }); + fireEvent.click(screen.getByText('atlas.markVisited')); + + const unmark = renderPopup({ confirmAction: { type: 'unmark-region', code: 'ES', name: 'Galicia' } }); + fireEvent.click(screen.getAllByText('atlas.unmark')[0]); + + await waitFor(() => expect(choose.atlas.setVisitedRegions).not.toHaveBeenCalled()); + expect(unmark.atlas.setVisitedRegions).not.toHaveBeenCalled(); + expect(markSpy).not.toHaveBeenCalled(); + expect(deleteSpy).not.toHaveBeenCalled(); + }); + + it('FE-MOB-ATLASPOP-023: a region removal keeps the country while another manual region remains', async () => { + const { atlas } = renderPopup({ + confirmAction: { type: 'unmark-region', code: 'IT', name: 'Lazio', regionCode: 'IT-62' }, + visitedRegions: { + IT: [ + { code: 'IT-62', name: 'Lazio', placeCount: 0, manuallyMarked: true }, + { code: 'IT-25', name: 'Lombardia', placeCount: 0, manuallyMarked: true }, + ], + } as VisitedRegions, + data: buildAtlasData({ + countries: [{ code: 'IT', tripCount: 0, placeCount: 0 }], + stats: { totalTrips: 0, totalPlaces: 0, totalCountries: 1, totalDays: 0 }, + }), + }); + + fireEvent.click(screen.getByText('atlas.unmark')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect((atlas.data as AtlasData).countries).toHaveLength(1); + }); + + it('FE-MOB-ATLASPOP-024: mark and bucket actions cope with atlas data that never loaded', async () => { + const { atlas } = renderPopup({ confirmAction: chooseAction(), data: null }); + + fireEvent.click(screen.getByText('atlas.markVisited')); + + await waitFor(() => expect(atlas.setConfirmAction).toHaveBeenCalledWith(null)); + expect(atlas.data).toBeNull(); + }); + + it('FE-MOB-ATLASPOP-025: dismissing the sheet clears the pending action', () => { + const { atlas } = renderPopup({ confirmAction: chooseAction() }); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(atlas.setConfirmAction).toHaveBeenCalledWith(null); + }); +}); diff --git a/client/tests/unit/mobile/collections/MCollAddSheet.test.tsx b/client/tests/unit/mobile/collections/MCollAddSheet.test.tsx new file mode 100644 index 000000000..a1067131e --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollAddSheet.test.tsx @@ -0,0 +1,309 @@ +// FE-MOB-COLADD-001 to FE-MOB-COLADD-014 +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' +import { http, HttpResponse } from 'msw' +import userEvent from '@testing-library/user-event' +import { render, screen, fireEvent, waitFor } from '../../../helpers/render' +import { server } from '../../../helpers/msw/server' +import type { Category } from '../../../../src/types' +import MCollAddSheet from '../../../../src/mobile/screens/collections/MCollAddSheet' + +const t = (key: string) => key + +const categories = [ + { id: 3, name: 'Restaurant', color: '#E8843D', icon: 'utensils' }, + { id: 4, name: 'Museum', color: '#8b5cf6', icon: null }, +] as unknown as Category[] + +type Props = React.ComponentProps + +function baseProps(overrides: Partial = {}): Props { + return { + open: true, + collectionId: 1, + collectionName: 'Hamburg', + lists: [{ id: 1, name: 'Hamburg', color: '#38BDF8' }], + categories, + onClose: vi.fn(), + onAdded: vi.fn(), + t, + ...overrides, + } +} + +/** Search hit shaped like the maps proxy: lat arrives as a string, website empty. */ +const hit = { + name: 'Elbphilharmonie', + address: 'Platz der Deutschen Einheit 1', + lat: '53.5413', + lng: 9.9841, + google_place_id: 'gp-1', + google_ftid: 'ft-1', + osm_id: 'osm-1', + website: '', + phone: '+49 40 357666', +} + +function mockSearch(places: Record[] = [hit]) { + server.use(http.post('/api/maps/search', () => HttpResponse.json({ places }))) +} + +type AddToast = NonNullable +let toast: Mock + +/** Captures the last savePlace body so the payload mapping can be asserted. */ +function mockSave(response: Record = { place: { id: 5 } }) { + const seen: { body?: Record } = {} + server.use( + http.post('/api/addons/collections/places', async ({ request }) => { + seen.body = (await request.json()) as Record + return HttpResponse.json(response) + }), + ) + return seen +} + +describe('MCollAddSheet', () => { + beforeEach(() => { + toast = vi.fn(() => 1) + window.__addToast = toast + }) + + afterEach(() => { + delete window.__addToast + }) + + it('FE-MOB-COLADD-001: stays unmounted while closed and skips the list picker for a fixed list', () => { + const { rerender } = render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + rerender() + expect(screen.getByRole('dialog', { name: 'collections.addPlace' })).toBeInTheDocument() + expect(screen.queryByText('COLLECTIONS.PICKLIST')).not.toBeInTheDocument() + // Nothing typed yet → the add action is unreachable. + expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled() + }) + + it('FE-MOB-COLADD-002: searching lists the hits and picking one fills name, address and query', async () => { + const user = userEvent.setup() + mockSearch() + render() + + await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil') + await user.click(screen.getByRole('button', { name: 'common.search' })) + + const hitRow = await screen.findByText('Elbphilharmonie') + expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument() + + await user.click(hitRow) + expect(screen.getByPlaceholderText('common.name')).toHaveValue('Elbphilharmonie') + expect(screen.getByPlaceholderText('collections.addPlaceSearch')).toHaveValue('Elbphilharmonie') + // The result list collapses once a hit is taken, the address stays as a hint. + expect(screen.queryByText('COMMON.SEARCH')).not.toBeInTheDocument() + expect(screen.getByText('Platz der Deutschen Einheit 1')).toBeInTheDocument() + }) + + it('FE-MOB-COLADD-003: Enter in the search field runs the search and the panel can be dismissed', async () => { + const user = userEvent.setup() + mockSearch() + render() + + const input = screen.getByPlaceholderText('collections.addPlaceSearch') + await user.type(input, 'elbphil{Enter}') + expect(await screen.findByText('Elbphilharmonie')).toBeInTheDocument() + + // The result panel has its own close affordance (there is also the sheet's). + const closers = screen.getAllByRole('button', { name: 'common.close' }) + await user.click(closers[closers.length - 1]) + expect(screen.queryByText('Elbphilharmonie')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLADD-004: a failing maps search surfaces the provider error as a toast', async () => { + const user = userEvent.setup() + server.use( + http.post('/api/maps/search', () => + HttpResponse.json({ error: 'Maps provider unavailable' }, { status: 502 }), + ), + ) + render() + + await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'x') + await user.click(screen.getByRole('button', { name: 'common.search' })) + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Maps provider unavailable', 'error', undefined)) + // The search button becomes usable again after the failure. + expect(screen.getByRole('button', { name: 'common.search' })).toBeEnabled() + }) + + it('FE-MOB-COLADD-005: an empty query keeps the search button disabled', () => { + render() + expect(screen.getByRole('button', { name: 'common.search' })).toBeDisabled() + }) + + it('FE-MOB-COLADD-006: saving a picked place maps the maps payload onto the save request', async () => { + const user = userEvent.setup() + mockSearch() + const seen = mockSave() + const props = baseProps() + render() + + await user.type(screen.getByPlaceholderText('collections.addPlaceSearch'), 'elbphil') + await user.click(screen.getByRole('button', { name: 'common.search' })) + await user.click(await screen.findByText('Elbphilharmonie')) + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(seen.body).toBeDefined()) + expect(seen.body).toMatchObject({ + collection_id: 1, + name: 'Elbphilharmonie', + address: 'Platz der Deutschen Einheit 1', + // lat arrives as a string from the proxy and has to be coerced. + lat: 53.5413, + lng: 9.9841, + google_place_id: 'gp-1', + google_ftid: 'ft-1', + osm_id: 'osm-1', + // An empty website string must not be stored as ''. + website: null, + phone: '+49 40 357666', + status: 'idea', + force: true, + }) + expect(toast).toHaveBeenCalledWith('collections.addedToList', 'success', undefined) + expect(props.onAdded).toHaveBeenCalledTimes(1) + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLADD-007: a hand-typed place saves without any maps provenance', async () => { + const user = userEvent.setup() + const seen = mockSave() + const props = baseProps() + render() + + await user.type(screen.getByPlaceholderText('common.name'), ' Sternschanze ') + await user.type(screen.getByPlaceholderText('collections.descriptionPlaceholder'), 'Streetfood') + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(seen.body).toBeDefined()) + expect(seen.body).toMatchObject({ + name: 'Sternschanze', + address: null, + lat: null, + lng: null, + google_place_id: null, + description: 'Streetfood', + category_id: null, + }) + expect(props.onAdded).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLADD-008: the status chips and the category picker feed the save request', async () => { + const user = userEvent.setup() + const seen = mockSave() + render() + + await user.type(screen.getByPlaceholderText('common.name'), 'Museum') + await user.click(screen.getByRole('button', { name: /collections.status.visited/ })) + expect(screen.getByRole('button', { name: /collections.status.visited/ })).toHaveAttribute('aria-pressed', 'true') + + await user.click(screen.getByText('collections.noCategory')) + await user.click(screen.getByText('Museum', { selector: 'span' })) + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(seen.body).toBeDefined()) + expect(seen.body).toMatchObject({ status: 'visited', category_id: 4 }) + }) + + it('FE-MOB-COLADD-009: a duplicate reported by the server informs instead of confirming', async () => { + const user = userEvent.setup() + mockSave({ duplicate: true, duplicateOf: { id: 2, name: 'Sternschanze' } }) + const props = baseProps() + render() + + await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze') + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(toast).toHaveBeenCalledWith('collections.duplicateWarning', 'info', undefined)) + expect(props.onAdded).not.toHaveBeenCalled() + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLADD-010: a rejected save reports the server error and leaves the sheet open', async () => { + const user = userEvent.setup() + server.use( + http.post('/api/addons/collections/places', () => + HttpResponse.json({ error: 'List is full' }, { status: 400 }), + ), + ) + const props = baseProps() + render() + + await user.type(screen.getByPlaceholderText('common.name'), 'Sternschanze') + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(toast).toHaveBeenCalledWith('List is full', 'error', undefined)) + expect(props.onClose).not.toHaveBeenCalled() + expect(screen.getByRole('button', { name: /common.add/ })).toBeEnabled() + }) + + it('FE-MOB-COLADD-011: opened from "All saved" with several lists the target has to be picked first', async () => { + const user = userEvent.setup() + const seen = mockSave() + render( + , + ) + + expect(screen.getByText('COLLECTIONS.PICKLIST')).toBeInTheDocument() + await user.type(screen.getByPlaceholderText('common.name'), 'Tivoli') + expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled() + + await user.click(screen.getByRole('button', { name: 'Kopenhagen' })) + expect(screen.getByRole('button', { name: 'Kopenhagen' })).toHaveAttribute('aria-pressed', 'true') + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(seen.body).toBeDefined()) + expect(seen.body).toMatchObject({ collection_id: 2, name: 'Tivoli' }) + expect(toast).toHaveBeenCalledWith('collections.addedToList', 'success', undefined) + }) + + it('FE-MOB-COLADD-012: with exactly one owned list that list is preselected', async () => { + const user = userEvent.setup() + const seen = mockSave() + render() + + await user.type(screen.getByPlaceholderText('common.name'), 'Alster') + await user.click(screen.getByRole('button', { name: /common.add/ })) + + await waitFor(() => expect(seen.body).toBeDefined()) + expect(seen.body).toMatchObject({ collection_id: 1 }) + }) + + it('FE-MOB-COLADD-013: without any list the picker explains there is nothing to save into', () => { + render() + + expect(screen.getByText('collections.noListsYet')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /common.add/ })).toBeDisabled() + }) + + it('FE-MOB-COLADD-014: closing resets the form so the next open starts blank', async () => { + const user = userEvent.setup() + const props = baseProps() + const { rerender } = render() + + await user.type(screen.getByPlaceholderText('common.name'), 'Alster') + fireEvent.click(screen.getAllByRole('button', { name: 'common.close' })[0]) + expect(props.onClose).toHaveBeenCalledTimes(1) + + rerender() + rerender() + expect(screen.getByPlaceholderText('common.name')).toHaveValue('') + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollCategoryPicker.test.tsx b/client/tests/unit/mobile/collections/MCollCategoryPicker.test.tsx new file mode 100644 index 000000000..82dbbd2c5 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollCategoryPicker.test.tsx @@ -0,0 +1,104 @@ +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '../../../helpers/render' +import MCollCategoryPicker from '../../../../src/mobile/screens/collections/MCollCategoryPicker' +import type { Category } from '../../../../src/types' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CCATP-001 to FE-MOB-CCATP-010 + +const CATEGORIES = [ + // In CATEGORY_SPEC — takes the design colour/icon, not the DB row. + { id: 1, name: 'Restaurant', color: '#000000', icon: 'MapPin' }, + // Custom admin category — falls back to its own colour/icon. + { id: 2, name: 'Winery', color: '#123456', icon: 'Wine' }, +] as unknown as Category[] + +function Harness(props: Omit, 't'>) { + const { t } = useTranslation() + return +} + +function setup(value: number | null, categories: Category[] = CATEGORIES) { + const onChange = vi.fn<(id: number | null) => void>() + const view = render() + const trigger = screen.getAllByRole('button')[0] + return { ...view, onChange, trigger } +} + +describe('MCollCategoryPicker', () => { + it('FE-MOB-CCATP-001: closed with no value it reads "No category" and lists nothing', () => { + const { trigger } = setup(null) + expect(trigger).toHaveTextContent('No category') + expect(screen.getAllByRole('button')).toHaveLength(1) + expect(trigger.querySelector('.lucide-chevron-down')).not.toBeNull() + }) + + it('FE-MOB-CCATP-002: a selected category is shown on the trigger', () => { + const { trigger } = setup(2) + expect(trigger).toHaveTextContent('Winery') + }) + + it('FE-MOB-CCATP-003: a value with no matching category falls back to "No category"', () => { + const { trigger } = setup(99) + expect(trigger).toHaveTextContent('No category') + }) + + it('FE-MOB-CCATP-004: opening lists "No category" plus every category and flips the chevron', () => { + const { trigger } = setup(null) + fireEvent.click(trigger) + const options = screen.getAllByRole('button').slice(1) + expect(options.map(o => o.textContent?.trim())).toEqual(['No category', 'Restaurant', 'Winery']) + expect(trigger.querySelector('.lucide-chevron-up')).not.toBeNull() + }) + + it('FE-MOB-CCATP-005: picking a category reports its id and closes the list', () => { + const { trigger, onChange } = setup(null) + fireEvent.click(trigger) + fireEvent.click(screen.getAllByRole('button')[2]) + expect(onChange).toHaveBeenCalledWith(1) + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('FE-MOB-CCATP-006: picking "No category" reports null', () => { + const { trigger, onChange } = setup(1) + fireEvent.click(trigger) + fireEvent.click(screen.getAllByRole('button')[1]) + expect(onChange).toHaveBeenCalledWith(null) + }) + + it('FE-MOB-CCATP-007: only the active option carries the check mark', () => { + const { trigger } = setup(2) + fireEvent.click(trigger) + const options = screen.getAllByRole('button').slice(1) + expect(options[0].querySelector('.lucide-check')).toBeNull() + expect(options[1].querySelector('.lucide-check')).toBeNull() + expect(options[2].querySelector('.lucide-check')).not.toBeNull() + }) + + it('FE-MOB-CCATP-008: canonical categories use the design icon, custom ones their own', () => { + const { trigger } = setup(null) + fireEvent.click(trigger) + const options = screen.getAllByRole('button').slice(1) + // No category → CircleSlash, Restaurant → Utensils (CATEGORY_SPEC), Winery → its DB icon. + expect(options[0].querySelector('.lucide-circle-slash')).not.toBeNull() + expect(options[1].querySelector('.lucide-utensils')).not.toBeNull() + expect(options[2].querySelector('.lucide-wine')).not.toBeNull() + }) + + it('FE-MOB-CCATP-009: a second tap on the trigger closes the list again', () => { + const { trigger } = setup(null) + fireEvent.click(trigger) + expect(screen.getAllByRole('button')).toHaveLength(4) + fireEvent.click(trigger) + expect(screen.getAllByRole('button')).toHaveLength(1) + }) + + it('FE-MOB-CCATP-010: with no categories the list holds "No category" alone', () => { + const { trigger } = setup(null, []) + fireEvent.click(trigger) + const options = screen.getAllByRole('button').slice(1) + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('No category') + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollEditSheet.test.tsx b/client/tests/unit/mobile/collections/MCollEditSheet.test.tsx new file mode 100644 index 000000000..f18c06799 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollEditSheet.test.tsx @@ -0,0 +1,304 @@ +// FE-MOB-COLEDIT-001 to FE-MOB-COLEDIT-013 +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest' +import { http, HttpResponse } from 'msw' +import userEvent from '@testing-library/user-event' +import { render, screen, fireEvent, waitFor } from '../../../helpers/render' +import { server } from '../../../helpers/msw/server' +import type { Collection } from '@trek/shared' +import MCollEditSheet from '../../../../src/mobile/screens/collections/MCollEditSheet' +import { useCollectionStore } from '../../../../src/store/collectionStore' +import { SWATCH_COLORS } from '../../../../src/mobile/screens/collections/collectionsMobileModel' + +const t = (key: string) => key + +function buildList(over: Partial = {}): Collection { + return { + id: 4, + owner_id: 1, + name: 'Hamburg', + color: '#38BDF8', + description: 'Hafenrunde', + cover_image: '/uploads/covers/hh.jpg', + links: [{ label: 'Guide', url: 'https://hamburg.de' }], + is_owner: true, + ...over, + } +} + +type Props = React.ComponentProps + +function baseProps(overrides: Partial = {}): Props { + return { + target: 'new', + onClose: vi.fn(), + onCreated: vi.fn(), + onRequestDelete: vi.fn(), + t, + ...overrides, + } +} + +type CollStore = ReturnType +type AddToast = NonNullable + +let toast: Mock +let createCollection: Mock +let updateCollection: Mock +let uploadCover: Mock + +const origCreateObjectURL = URL.createObjectURL +const origRevokeObjectURL = URL.revokeObjectURL + +function stubObjectUrl(fn: unknown) { + Object.defineProperty(URL, 'createObjectURL', { writable: true, configurable: true, value: fn }) +} + +function unsplash(photos: Record[]) { + server.use(http.get('/api/trips/cover-images/search', () => HttpResponse.json({ photos }))) +} + +/** The cover images carry no alt text, so they are addressed by src. */ +const imageWithSrc = (src: string) => document.querySelector(`img[src="${src}"]`) + +describe('MCollEditSheet', () => { + beforeEach(() => { + toast = vi.fn(() => 1) + window.__addToast = toast + // jsdom's URL.createObjectURL rejects a jsdom File, so the cover preview + // needs a stub that just hands back a fake blob URL. + stubObjectUrl(vi.fn(() => 'blob:cover')) + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: vi.fn() }) + + createCollection = vi.fn(async () => buildList({ id: 11, name: 'Kopenhagen' })) + updateCollection = vi.fn(async () => undefined) + uploadCover = vi.fn(async () => undefined) + useCollectionStore.setState({ createCollection, updateCollection, uploadCover }) + }) + + afterEach(() => { + delete window.__addToast + stubObjectUrl(origCreateObjectURL) + Object.defineProperty(URL, 'revokeObjectURL', { writable: true, configurable: true, value: origRevokeObjectURL }) + }) + + it('FE-MOB-COLEDIT-001: stays unmounted for a null target and opens in create mode', () => { + const { rerender } = render() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + rerender() + expect(screen.getByRole('dialog', { name: 'collections.newList' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'collections.create' })).toBeDisabled() + // Creating has nothing to delete yet. + expect(screen.queryByRole('button', { name: /collections.deleteList/ })).not.toBeInTheDocument() + }) + + it('FE-MOB-COLEDIT-002: creating a list posts name, colour, description and normalised links', async () => { + const user = userEvent.setup() + const props = baseProps() + render() + + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), ' Kopenhagen ') + await user.type(screen.getByPlaceholderText('collections.descriptionPlaceholder'), ' Kanaltour ') + await user.click(screen.getByRole('button', { name: SWATCH_COLORS[2] })) + + // Two link rows: one bare host that must be normalised, one empty that drops out. + await user.click(screen.getByRole('button', { name: /collections.addLink/ })) + await user.click(screen.getByRole('button', { name: /collections.addLink/ })) + const urlInputs = screen.getAllByPlaceholderText('https://…') + await user.type(urlInputs[0], 'visitcopenhagen.com') + const labelInputs = screen.getAllByPlaceholderText('collections.linkLabel') + await user.type(labelInputs[0], ' Tips ') + + await user.click(screen.getByRole('button', { name: 'collections.create' })) + + await waitFor(() => expect(createCollection).toHaveBeenCalledTimes(1)) + expect(createCollection).toHaveBeenCalledWith({ + name: 'Kopenhagen', + color: SWATCH_COLORS[2], + description: 'Kanaltour', + links: [{ label: 'Tips', url: 'https://visitcopenhagen.com' }], + }) + expect(props.onCreated).toHaveBeenCalledWith(11) + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLEDIT-003: a link row can be removed again before saving', async () => { + const user = userEvent.setup() + render() + + expect(screen.getAllByPlaceholderText('https://…')).toHaveLength(1) + await user.click(screen.getByRole('button', { name: 'common.delete' })) + expect(screen.queryByPlaceholderText('https://…')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLEDIT-004: editing seeds the form and saves through updateCollection', async () => { + const user = userEvent.setup() + const props = baseProps({ target: buildList() }) + render() + + expect(screen.getByRole('dialog', { name: 'collections.editListTitle' })).toBeInTheDocument() + expect(screen.getByPlaceholderText('collections.listNamePlaceholder')).toHaveValue('Hamburg') + expect(screen.getByPlaceholderText('collections.descriptionPlaceholder')).toHaveValue('Hafenrunde') + expect(imageWithSrc('/uploads/covers/hh.jpg')).not.toBeNull() + // An existing cover offers to be replaced, not added. + expect(screen.getByText('collections.changeCover')).toBeInTheDocument() + + await user.clear(screen.getByPlaceholderText('collections.descriptionPlaceholder')) + await user.click(screen.getByRole('button', { name: 'common.save' })) + + await waitFor(() => expect(updateCollection).toHaveBeenCalledTimes(1)) + expect(updateCollection).toHaveBeenCalledWith(4, { + name: 'Hamburg', + color: '#38BDF8', + description: null, + links: [{ label: 'Guide', url: 'https://hamburg.de' }], + }) + // An edit must not re-navigate the way a create does. + expect(props.onCreated).not.toHaveBeenCalled() + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLEDIT-005: a list without a colour falls back to the first swatch', () => { + render() + + expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true') + expect(document.querySelector('img')).toBeNull() + expect(screen.getByText('collections.addCover')).toBeInTheDocument() + }) + + it('FE-MOB-COLEDIT-006: picking a file shows the local preview and uploads it after create', async () => { + const user = userEvent.setup() + render() + + const file = new File(['x'], 'cover.png', { type: 'image/png' }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(input, file) + expect(imageWithSrc('blob:cover')).not.toBeNull() + + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen') + await user.click(screen.getByRole('button', { name: 'collections.create' })) + + await waitFor(() => expect(uploadCover).toHaveBeenCalledWith(11, file)) + }) + + it('FE-MOB-COLEDIT-007: an aborted file dialog leaves the cover untouched', () => { + render() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const openDialog = vi.spyOn(input, 'click').mockImplementation(() => {}) + // The cover tile is the trigger for the hidden file input. + fireEvent.click(screen.getByText('collections.addCover')) + expect(openDialog).toHaveBeenCalledTimes(1) + + fireEvent.change(input, { target: { files: [] } }) + expect(document.querySelector('img')).toBeNull() + }) + + it('FE-MOB-COLEDIT-008: the Unsplash search offers photos and the pick is sent as cover_image', async () => { + const user = userEvent.setup() + unsplash([ + { id: 'p1', url: 'https://img/full1.jpg', thumb: 'https://img/t1.jpg', description: 'Canal', photographer: 'Ada' }, + { id: 'p2', url: 'https://img/full2.jpg', thumb: 'https://img/t2.jpg' }, + ]) + render() + + await user.type(screen.getByPlaceholderText('dashboard.unsplashSearchPlaceholder'), 'copenhagen') + await user.click(screen.getByRole('button', { name: /Unsplash/ })) + + const photo = await screen.findByRole('button', { name: 'Ada' }) + // The photographer credit is shown; a photo without one still renders its thumb. + expect(screen.getByText('Ada')).toBeInTheDocument() + expect(imageWithSrc('https://img/t2.jpg')).not.toBeNull() + + await user.click(photo) + // The picked photo becomes the preview above the grid. + expect(imageWithSrc('https://img/full1.jpg')).not.toBeNull() + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen') + await user.click(screen.getByRole('button', { name: 'collections.create' })) + + await waitFor(() => expect(createCollection).toHaveBeenCalledTimes(1)) + expect(createCollection).toHaveBeenCalledWith(expect.objectContaining({ cover_image: 'https://img/full1.jpg' })) + expect(uploadCover).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLEDIT-009: a photo without a url is not selectable and a failing search clears the grid', async () => { + const user = userEvent.setup() + unsplash([{ id: 'p0', url: '', thumb: 'https://img/t0.jpg', photographer: 'Nobody' }]) + render() + + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen') + // With no query the list name is used as the search term. + await user.click(screen.getByRole('button', { name: /Unsplash/ })) + await user.click(await screen.findByRole('button', { name: 'Nobody' })) + // Only the thumb is on screen — a url-less photo never becomes the cover. + expect(imageWithSrc('https://img/t0.jpg')).not.toBeNull() + expect(document.querySelectorAll('img')).toHaveLength(1) + + server.use(http.get('/api/trips/cover-images/search', () => new HttpResponse(null, { status: 500 }))) + await user.click(screen.getByRole('button', { name: /Unsplash/ })) + await waitFor(() => expect(screen.queryByRole('button', { name: 'Nobody' })).not.toBeInTheDocument()) + }) + + it('FE-MOB-COLEDIT-010: Unsplash stays idle while there is neither a query nor a name', async () => { + const user = userEvent.setup() + const search = vi.fn(() => HttpResponse.json({ photos: [] })) + server.use(http.get('/api/trips/cover-images/search', search)) + render() + + expect(screen.getByRole('button', { name: /Unsplash/ })).toBeDisabled() + // Enter bypasses the disabled button, so the guard has to hold there too. + await user.type(screen.getByPlaceholderText('dashboard.unsplashSearchPlaceholder'), ' {Enter}') + expect(search).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLEDIT-011: a failed save reports the error and a retry updates the already-created list', async () => { + const user = userEvent.setup() + uploadCover.mockRejectedValueOnce(Object.assign(new Error('boom'), { response: { data: { error: 'Cover too large' } } })) + const props = baseProps() + render() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(input, new File(['x'], 'cover.png', { type: 'image/png' })) + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen') + await user.click(screen.getByRole('button', { name: 'collections.create' })) + + await waitFor(() => expect(toast).toHaveBeenCalledWith('Cover too large', 'error', undefined)) + expect(props.onClose).not.toHaveBeenCalled() + + // The list already exists — the retry must not create a second one. + await user.click(screen.getByRole('button', { name: 'collections.create' })) + await waitFor(() => expect(updateCollection).toHaveBeenCalledWith(11, expect.objectContaining({ name: 'Kopenhagen' }))) + expect(createCollection).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLEDIT-012: a create that returns no list keeps the cover upload from running', async () => { + const user = userEvent.setup() + createCollection.mockResolvedValueOnce(null) + const props = baseProps() + render() + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + await user.upload(input, new File(['x'], 'cover.png', { type: 'image/png' })) + await user.type(screen.getByPlaceholderText('collections.listNamePlaceholder'), 'Kopenhagen') + await user.click(screen.getByRole('button', { name: 'collections.create' })) + + await waitFor(() => expect(props.onClose).toHaveBeenCalledTimes(1)) + expect(uploadCover).not.toHaveBeenCalled() + expect(props.onCreated).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLEDIT-013: the owner can delete from the footer, a co-owner cannot', () => { + const props = baseProps({ target: buildList() }) + const { rerender } = render() + + fireEvent.click(screen.getByRole('button', { name: /collections.deleteList/ })) + expect(props.onClose).toHaveBeenCalledTimes(1) + expect(props.onRequestDelete).toHaveBeenCalledWith(4) + + rerender() + expect(screen.queryByRole('button', { name: /collections.deleteList/ })).not.toBeInTheDocument() + // Switching target re-seeds the form. + expect(screen.getByPlaceholderText('collections.listNamePlaceholder')).toHaveValue('Julien Tipps') + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollLabelsSheet.test.tsx b/client/tests/unit/mobile/collections/MCollLabelsSheet.test.tsx new file mode 100644 index 000000000..194052152 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollLabelsSheet.test.tsx @@ -0,0 +1,212 @@ +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import MCollLabelsSheet from '../../../../src/mobile/screens/collections/MCollLabelsSheet' +import type { LabelOption } from '../../../../src/pages/collections/collectionsModel' +import { SWATCH_COLORS } from '../../../../src/mobile/screens/collections/collectionsMobileModel' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CLBLS-001 to FE-MOB-CLBLS-018 + +const LABELS: LabelOption[] = [ + { id: 1, name: 'Food', color: '#EF4444', count: 4 }, + // No colour — falls back to the first swatch. + { id: 2, name: 'Museums', color: null, count: 0 }, +] + +function Harness(props: Omit, 't'>) { + const { t } = useTranslation() + return +} + +type Props = ComponentProps + +function setup(over: Partial = {}) { + const onCreate = vi.fn().mockResolvedValue(undefined) + const onUpdate = vi.fn().mockResolvedValue(undefined) + const onDelete = vi.fn().mockResolvedValue(undefined) + const onAssign = vi.fn().mockResolvedValue(undefined) + const onSwitchToManage = vi.fn() + const onClose = vi.fn() + const props = { + open: true, + mode: 'manage' as const, + labels: LABELS, + selectedCount: 2, + onCreate, + onUpdate, + onDelete, + onAssign, + onSwitchToManage, + onClose, + ...over, + } + const view = render() + return { ...view, onCreate, onUpdate, onDelete, onAssign, onSwitchToManage, onClose, props } +} + +describe('MCollLabelsSheet', () => { + it('FE-MOB-CLBLS-001: manage mode heads the sheet and lists every label with its usage count', () => { + setup() + expect(screen.getByRole('dialog', { name: 'Manage labels' })).toBeInTheDocument() + expect(screen.getByText('Food')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + expect(screen.getByText('Museums')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('FE-MOB-CLBLS-002: the add button stays disabled until the name has content', () => { + setup() + const add = screen.getByRole('button', { name: /Add label/ }) + expect(add).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: ' ' } }) + expect(add).toBeDisabled() + + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } }) + expect(add).not.toBeDisabled() + }) + + it('FE-MOB-CLBLS-003: creating a label trims the name and sends the default swatch', async () => { + const { onCreate } = setup() + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: ' Bars ' } }) + fireEvent.click(screen.getByRole('button', { name: /Add label/ })) + await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[0])) + // The form resets for the next label. + await waitFor(() => expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('')) + }) + + it('FE-MOB-CLBLS-004: the picked swatch is sent along and marked pressed', async () => { + const { onCreate } = setup() + fireEvent.click(screen.getByRole('button', { name: SWATCH_COLORS[3] })) + expect(screen.getByRole('button', { name: SWATCH_COLORS[3] })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'false') + + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } }) + fireEvent.click(screen.getByRole('button', { name: /Add label/ })) + await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[3])) + }) + + it('FE-MOB-CLBLS-005: Enter in the name field submits, other keys do not', async () => { + const { onCreate } = setup() + const input = screen.getByPlaceholderText('e.g. Berlin') + fireEvent.change(input, { target: { value: 'Bars' } }) + fireEvent.keyDown(input, { key: 'a' }) + expect(onCreate).not.toHaveBeenCalled() + + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(onCreate).toHaveBeenCalledWith('Bars', SWATCH_COLORS[0])) + }) + + it('FE-MOB-CLBLS-006: a blank name never reaches the callback', () => { + const { onCreate } = setup() + fireEvent.keyDown(screen.getByPlaceholderText('e.g. Berlin'), { key: 'Enter' }) + expect(onCreate).not.toHaveBeenCalled() + }) + + it('FE-MOB-CLBLS-007: the pencil seeds the form and switches the button to Save', async () => { + const { onUpdate, onCreate } = setup() + fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]) + expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('Food') + expect(screen.getByRole('button', { name: '#EF4444' })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Eating' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onUpdate).toHaveBeenCalledWith(1, { name: 'Eating', color: '#EF4444' })) + expect(onCreate).not.toHaveBeenCalled() + // Back to create mode once the save has settled. + await waitFor(() => expect(screen.getByRole('button', { name: /Add label/ })).toBeInTheDocument()) + }) + + it('FE-MOB-CLBLS-008: editing a colourless label starts from the first swatch', () => { + setup() + fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[1]) + expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('Museums') + expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-MOB-CLBLS-009: the trash icon deletes that label', () => { + const { onDelete } = setup() + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]) + expect(onDelete).toHaveBeenCalledWith(2) + }) + + it('FE-MOB-CLBLS-010: a second submit is swallowed while the first is still running', async () => { + let resolve!: () => void + const onCreate = vi.fn().mockReturnValue(new Promise(r => { resolve = r })) + setup({ onCreate }) + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } }) + fireEvent.click(screen.getByRole('button', { name: /Add label/ })) + fireEvent.click(screen.getByRole('button', { name: /Add label/ })) + expect(onCreate).toHaveBeenCalledTimes(1) + + resolve() + await waitFor(() => expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('')) + }) + + it('FE-MOB-CLBLS-011: manage mode with no labels shows the empty line without the assign hint', () => { + setup({ labels: [] }) + expect(screen.getByText('No labels yet')).toBeInTheDocument() + expect(screen.queryByText('Create a label first to group places in this list.')).not.toBeInTheDocument() + }) + + it('FE-MOB-CLBLS-012: assign mode titles with the selection size and drops the create form', () => { + setup({ mode: 'assign' }) + expect(screen.getByRole('dialog', { name: 'Add labels to 2 places' })).toBeInTheDocument() + expect(screen.queryByPlaceholderText('e.g. Berlin')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Assign label/ })).toBeDisabled() + }) + + it('FE-MOB-CLBLS-013: assign mode toggles labels as checkboxes and sends the checked ids', async () => { + const { onAssign } = setup({ mode: 'assign' }) + const boxes = screen.getAllByRole('checkbox') + expect(boxes[0]).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(boxes[0]) + fireEvent.click(boxes[1]) + expect(boxes[0]).toHaveAttribute('aria-checked', 'true') + expect(boxes[0].querySelector('.lucide-check')).not.toBeNull() + + // Untick the second one again — only the first is submitted. + fireEvent.click(boxes[1]) + expect(boxes[1]).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: /Assign label/ })) + await waitFor(() => expect(onAssign).toHaveBeenCalledWith([1])) + }) + + it('FE-MOB-CLBLS-014: assigning nothing never reaches the callback', () => { + const { onAssign } = setup({ mode: 'assign' }) + fireEvent.click(screen.getByRole('button', { name: /Assign label/ })) + expect(onAssign).not.toHaveBeenCalled() + }) + + it('FE-MOB-CLBLS-015: the footer link switches over to label management', () => { + const { onSwitchToManage } = setup({ mode: 'assign' }) + fireEvent.click(screen.getByRole('button', { name: 'Manage labels' })) + expect(onSwitchToManage).toHaveBeenCalled() + }) + + it('FE-MOB-CLBLS-016: assign mode with no labels adds the "create one first" hint', () => { + setup({ mode: 'assign', labels: [] }) + expect(screen.getByText('No labels yet')).toBeInTheDocument() + expect(screen.getByText('Create a label first to group places in this list.')).toBeInTheDocument() + }) + + it('FE-MOB-CLBLS-017: closing the sheet clears the draft, and the header close hands back', () => { + const { rerender, props, onClose } = setup() + fireEvent.change(screen.getByPlaceholderText('e.g. Berlin'), { target: { value: 'Bars' } }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + + rerender() + rerender() + expect(screen.getByPlaceholderText('e.g. Berlin')).toHaveValue('') + expect(screen.getByRole('button', { name: SWATCH_COLORS[0] })).toHaveAttribute('aria-pressed', 'true') + }) + + it('FE-MOB-CLBLS-018: a closed sheet renders nothing at all', () => { + setup({ open: false }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollLinksEditor.test.tsx b/client/tests/unit/mobile/collections/MCollLinksEditor.test.tsx new file mode 100644 index 000000000..402345642 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollLinksEditor.test.tsx @@ -0,0 +1,80 @@ +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { CollectionLink } from '@trek/shared' +import { fireEvent, render, screen } from '../../../helpers/render' +import MCollLinksEditor from '../../../../src/mobile/screens/collections/MCollLinksEditor' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CLNKED-001 to FE-MOB-CLNKED-006 +// The editor reads its copy through the real TranslationProvider, so the +// assertions use the English strings. + +function Harness(props: Omit, 't'>) { + const { t } = useTranslation() + return +} + +function setup(links: CollectionLink[]) { + const onChange = vi.fn<(next: CollectionLink[]) => void>() + const view = render() + return { ...view, onChange } +} + +const LINKS: CollectionLink[] = [ + { label: 'Menu', url: 'https://example.com/menu' }, + { url: 'https://booking.com/hotel' }, +] + +describe('MCollLinksEditor', () => { + it('FE-MOB-CLNKED-001: renders one label/url pair per link and an add row', () => { + setup(LINKS) + const labels = screen.getAllByPlaceholderText('Label') + const urls = screen.getAllByPlaceholderText('https://…') + expect(labels).toHaveLength(2) + expect(labels[0]).toHaveValue('Menu') + // A link without a label still renders an empty (controlled) input. + expect(labels[1]).toHaveValue('') + expect(urls[0]).toHaveValue('https://example.com/menu') + expect(urls[1]).toHaveValue('https://booking.com/hotel') + expect(screen.getByRole('button', { name: /Add link/ })).toBeInTheDocument() + }) + + it('FE-MOB-CLNKED-002: editing a label patches only that row', () => { + const { onChange } = setup(LINKS) + fireEvent.change(screen.getAllByPlaceholderText('Label')[1], { target: { value: 'Hotel' } }) + expect(onChange).toHaveBeenCalledWith([ + { label: 'Menu', url: 'https://example.com/menu' }, + { label: 'Hotel', url: 'https://booking.com/hotel' }, + ]) + }) + + it('FE-MOB-CLNKED-003: editing a url patches only that row', () => { + const { onChange } = setup(LINKS) + fireEvent.change(screen.getAllByPlaceholderText('https://…')[0], { target: { value: 'https://example.com/card' } }) + expect(onChange).toHaveBeenCalledWith([ + { label: 'Menu', url: 'https://example.com/card' }, + { url: 'https://booking.com/hotel' }, + ]) + }) + + it('FE-MOB-CLNKED-004: the trash button drops that row', () => { + const { onChange } = setup(LINKS) + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[0]) + expect(onChange).toHaveBeenCalledWith([{ url: 'https://booking.com/hotel' }]) + }) + + it('FE-MOB-CLNKED-005: the add row appends an empty link', () => { + const { onChange } = setup(LINKS) + fireEvent.click(screen.getByRole('button', { name: /Add link/ })) + expect(onChange).toHaveBeenCalledWith([...LINKS, { url: '' }]) + }) + + it('FE-MOB-CLNKED-006: an empty list shows the add row only', () => { + const { onChange } = setup([]) + expect(screen.queryByPlaceholderText('Label')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Add link/ })) + expect(onChange).toHaveBeenCalledWith([{ url: '' }]) + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollListPickerSheet.test.tsx b/client/tests/unit/mobile/collections/MCollListPickerSheet.test.tsx new file mode 100644 index 000000000..5546dddca --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollListPickerSheet.test.tsx @@ -0,0 +1,105 @@ +import type { ComponentProps } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { Collection } from '@trek/shared' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import MCollListPickerSheet, { + type MCollListPickerMode, +} from '../../../../src/mobile/screens/collections/MCollListPickerSheet' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CLISTP-001 to FE-MOB-CLISTP-010 + +const LISTS = [ + { id: 1, owner_id: 3, name: 'Tokyo 2026', color: '#38BDF8', place_count: 12 }, + // No colour and no count — both fall back. + { id: 2, owner_id: 3, name: 'Someday' }, +] as unknown as Collection[] + +function Harness(props: Omit, 't'>) { + const { t } = useTranslation() + return +} + +function setup(over: Partial> = {}) { + const onPick = vi.fn<(id: number) => Promise>().mockResolvedValue(undefined) + const onClose = vi.fn() + const props = { mode: 'move' as MCollListPickerMode | null, lists: LISTS, count: 3, onPick, onClose, ...over } + const view = render() + return { ...view, onPick, onClose, props } +} + +describe('MCollListPickerSheet', () => { + it('FE-MOB-CLISTP-001: move mode titles the sheet with the selection size', () => { + setup() + expect(screen.getByRole('dialog', { name: 'Move 3 to another list' })).toBeInTheDocument() + expect(screen.getByText('Move 3 to another list')).toBeInTheDocument() + }) + + it('FE-MOB-CLISTP-002: copy mode uses the duplicate wording', () => { + setup({ mode: 'copy', count: 1 }) + expect(screen.getByRole('dialog', { name: 'Duplicate 1 to another list' })).toBeInTheDocument() + }) + + it('FE-MOB-CLISTP-003: renders one row per list with its place count', () => { + setup() + expect(screen.getByRole('button', { name: /Tokyo 2026/ })).toHaveTextContent('12') + expect(screen.getByRole('button', { name: /Someday/ })).toHaveTextContent('0') + }) + + it('FE-MOB-CLISTP-004: an empty target list explains there is nowhere to move to', () => { + setup({ lists: [] }) + expect(screen.getByText('No other lists yet')).toBeInTheDocument() + }) + + it('FE-MOB-CLISTP-005: picking a list hands the target id to the caller', async () => { + const { onPick } = setup() + fireEvent.click(screen.getByRole('button', { name: /Tokyo 2026/ })) + await waitFor(() => expect(onPick).toHaveBeenCalledWith(1)) + }) + + it('FE-MOB-CLISTP-006: while a pick runs the row spins and every row is locked', async () => { + let resolve!: () => void + const onPick = vi.fn<(id: number) => Promise>().mockReturnValue(new Promise(r => { resolve = r })) + setup({ onPick }) + const first = screen.getByRole('button', { name: /Tokyo 2026/ }) + fireEvent.click(first) + + expect(first.querySelector('.animate-spin')).not.toBeNull() + expect(screen.getByRole('button', { name: /Someday/ })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: /Someday/ })) + expect(onPick).toHaveBeenCalledTimes(1) + + resolve() + await waitFor(() => expect(first.querySelector('.animate-spin')).toBeNull()) + }) + + it('FE-MOB-CLISTP-007: once a pick has settled another list can be picked', async () => { + const { onPick } = setup({ mode: 'copy' }) + fireEvent.click(screen.getByRole('button', { name: /Tokyo 2026/ })) + await waitFor(() => expect(screen.getByRole('button', { name: /Someday/ })).not.toBeDisabled()) + + fireEvent.click(screen.getByRole('button', { name: /Someday/ })) + await waitFor(() => expect(onPick).toHaveBeenNthCalledWith(2, 2)) + }) + + it('FE-MOB-CLISTP-008: the header close hands back to the caller', () => { + const { onClose } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + }) + + it('FE-MOB-CLISTP-009: mode null keeps the sheet unmounted', () => { + setup({ mode: null }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-CLISTP-010: the title follows the mode it is opened with, and is held on close', () => { + const { rerender, props } = setup({ mode: null }) + rerender() + expect(screen.getByRole('dialog', { name: 'Duplicate 3 to another list' })).toBeInTheDocument() + + // Closing keeps the last title through the exit animation. + rerender() + expect(screen.getByText('Duplicate 3 to another list')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollListsDrawer.test.tsx b/client/tests/unit/mobile/collections/MCollListsDrawer.test.tsx new file mode 100644 index 000000000..f6728cff9 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollListsDrawer.test.tsx @@ -0,0 +1,122 @@ +// FE-MOB-COLDRAW-001 to FE-MOB-COLDRAW-008 +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import type { Collection } from '@trek/shared' +import { render, screen, fireEvent } from '../../../helpers/render' +import MCollListsDrawer from '../../../../src/mobile/screens/collections/MCollListsDrawer' +import { ALL_SAVED } from '../../../../src/store/collectionStore' +import type { IncomingCollectionInvite } from '../../../../src/store/collectionStore' + +const t = (key: string) => key + +function buildList(over: Partial = {}): Collection { + return { id: 1, owner_id: 1, name: 'Hamburg', color: '#38BDF8', place_count: 4, is_owner: true, ...over } +} + +const invite: IncomingCollectionInvite = { + collection_id: 42, + name: 'Roadtrip 2027', + from: { id: 9, username: 'julien' }, +} + +type Props = React.ComponentProps + +function renderDrawer(overrides: Partial = {}) { + const props: Props = { + open: true, + onClose: vi.fn(), + ownedLists: [buildList()], + sharedLists: [], + activeId: ALL_SAVED, + incomingInvites: [], + onSelect: vi.fn(), + onNewList: vi.fn(), + onAcceptInvite: vi.fn(), + onDeclineInvite: vi.fn(), + t, + ...overrides, + } + render() + return props +} + +describe('MCollListsDrawer', () => { + it('FE-MOB-COLDRAW-001: renders nothing while closed', () => { + renderDrawer({ open: false }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLDRAW-002: lists the owned lists with their place counts above the union entry', () => { + renderDrawer({ + ownedLists: [buildList(), buildList({ id: 2, name: 'Kopenhagen', place_count: undefined })], + }) + + expect(screen.getByRole('dialog', { name: 'collections.title' })).toBeInTheDocument() + expect(screen.getByText('Hamburg')).toBeInTheDocument() + expect(screen.getByText('4')).toBeInTheDocument() + // A list the server did not count yet falls back to 0. + expect(screen.getByText('Kopenhagen')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + }) + + it('FE-MOB-COLDRAW-003: selecting the union and a list reports the right id', () => { + const props = renderDrawer() + + fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ })) + expect(props.onSelect).toHaveBeenCalledWith(ALL_SAVED) + + fireEvent.click(screen.getByText('Hamburg')) + expect(props.onSelect).toHaveBeenCalledWith(1) + }) + + it('FE-MOB-COLDRAW-004: the new-list and close buttons call their handlers', () => { + const props = renderDrawer() + + fireEvent.click(screen.getByRole('button', { name: /collections.newList/ })) + expect(props.onNewList).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'common.close' })) + expect(props.onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLDRAW-005: the active list is highlighted, the union entry is not', () => { + renderDrawer({ activeId: 1 }) + + const row = screen.getByText('Hamburg').closest('button') as HTMLElement + expect(row.className).toContain('bg-[color:var(--m-ic)]') + expect(row.style.boxShadow).toContain('inset 0 0 0 1.5px') + + const union = screen.getByRole('button', { name: /collections.allSaved/ }) + expect(union.className).not.toContain('bg-[color:var(--m-ic)]') + }) + + it('FE-MOB-COLDRAW-006: shared lists get their own section, absent when there are none', () => { + renderDrawer() + expect(screen.queryByText('collections.shared')).not.toBeInTheDocument() + + renderDrawer({ sharedLists: [buildList({ id: 7, name: 'Julien Tipps', color: null, is_owner: false })] }) + expect(screen.getByText('collections.shared')).toBeInTheDocument() + expect(screen.getByText('Julien Tipps')).toBeInTheDocument() + }) + + it('FE-MOB-COLDRAW-007: pending invites show the inviter and accept/decline by collection id', () => { + const props = renderDrawer({ incomingInvites: [invite] }) + + expect(screen.getByText('collections.invites.title')).toBeInTheDocument() + expect(screen.getByText('Roadtrip 2027')).toBeInTheDocument() + expect(screen.getByText(/julien/)).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'collections.invites.accept' })) + expect(props.onAcceptInvite).toHaveBeenCalledWith(42) + + fireEvent.click(screen.getByRole('button', { name: 'collections.invites.decline' })) + expect(props.onDeclineInvite).toHaveBeenCalledWith(42) + }) + + it('FE-MOB-COLDRAW-008: a list without a colour falls back to the default swatch', () => { + renderDrawer({ ownedLists: [buildList({ color: null })] }) + + const dot = screen.getByText('Hamburg').previousElementSibling as HTMLElement + expect(dot).toHaveStyle({ background: '#6366F1' }) + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx new file mode 100644 index 000000000..054ac0711 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollPlaceSheet.test.tsx @@ -0,0 +1,381 @@ +import type { ComponentProps } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { CollectionLabel, CollectionPlace } from '@trek/shared' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import MCollPlaceSheet from '../../../../src/mobile/screens/collections/MCollPlaceSheet' +import { mapsApi } from '../../../../src/api/client' +import type { Category } from '../../../../src/types' +import { resetAllStores } from '../../../helpers/store' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CPLSH-001 to FE-MOB-CPLSH-033 + +// react-markdown ships ESM-only chunks jsdom chokes on; the sheet only needs +// the raw description text to reach the renderer. +vi.mock('react-markdown', () => ({ + default: ({ children }: { children: string }) => {children}, +})) +vi.mock('remark-gfm', () => ({ default: () => ({}) })) +vi.mock('remark-breaks', () => ({ default: () => ({}) })) + +type Props = ComponentProps + +const CATEGORIES = [ + { id: 3, name: 'Restaurant', color: '#E8843D', icon: 'Utensils' }, + { id: 5, name: 'Hotel', color: '#9B5DE5', icon: 'BedDouble' }, +] as unknown as Category[] + +const LABELS: CollectionLabel[] = [ + { id: 11, collection_id: 1, name: 'Must see', color: '#EF4444' }, + { id: 12, collection_id: 1, name: 'Rainy day', color: null }, +] + +function place(over: Partial = {}): CollectionPlace { + return { + id: 7, + collection_id: 1, + name: 'Narisawa', + address: '2-6-15 Minami-Aoyama', + status: 'want', + category_id: 3, + category: { id: 3, name: 'Restaurant', color: '#E8843D', icon: 'Utensils' }, + description: 'Two stars, book early', + links: [{ label: 'Menu', url: 'https://narisawa.example/menu' }, { url: 'https://www.booking.example/x' }], + label_ids: [11], + ...over, + } as CollectionPlace +} + +type AddToast = NonNullable +let addToast: Mock + +function Harness(props: Omit) { + const { t } = useTranslation() + return +} + +function setup(over: Partial = {}) { + const onClose = vi.fn() + const onSetStatus = vi.fn() + const onSave = vi.fn().mockResolvedValue(undefined) + const onCopyToTrip = vi.fn() + const onRemove = vi.fn() + const props: Omit = { + place: place(), + canEdit: true, + canDelete: true, + categories: CATEGORIES, + labels: LABELS, + onClose, + onSetStatus, + onSave, + onCopyToTrip, + onRemove, + ...over, + } + const view = render() + return { ...view, onClose, onSetStatus, onSave, onCopyToTrip, onRemove, props } +} + +function pngFile(name = 'cover.png') { + return new File(['x'], name, { type: 'image/png' }) +} + +describe('MCollPlaceSheet', () => { + beforeEach(() => { + resetAllStores() + addToast = vi.fn() + window.__addToast = addToast + vi.spyOn(mapsApi, 'placePhoto').mockResolvedValue({ photoUrl: null }) + }) + + afterEach(() => { + vi.restoreAllMocks() + delete window.__addToast + }) + + it('FE-MOB-CPLSH-001: heads the sheet with the place, its category chip and address', () => { + setup() + expect(screen.getByRole('dialog', { name: 'Narisawa' })).toBeInTheDocument() + expect(screen.getByText('Narisawa')).toBeInTheDocument() + expect(screen.getByText('Restaurant')).toBeInTheDocument() + expect(screen.getByText('2-6-15 Minami-Aoyama')).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-002: null place keeps the sheet unmounted', () => { + setup({ place: null }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-003: a place without a category or address drops both rows', () => { + setup({ place: place({ category: undefined, category_id: null, address: null }) }) + expect(screen.queryByText('Restaurant')).not.toBeInTheDocument() + expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-004: the status cycle marks the current status and reports a new one', () => { + const { onSetStatus } = setup() + expect(screen.getByRole('button', { name: 'Want to go' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Idea' })).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Visited' })) + expect(onSetStatus).toHaveBeenCalledWith('visited') + }) + + it('FE-MOB-CPLSH-005: a read-only member cannot change the status or edit', () => { + setup({ canEdit: false }) + expect(screen.getByRole('button', { name: 'Visited' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-006: assigned labels are shown, unassigned ones are not', () => { + setup() + expect(screen.getByText('Must see')).toBeInTheDocument() + expect(screen.queryByText('Rainy day')).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-007: the description is rendered as markdown', () => { + setup() + expect(screen.getByTestId('md')).toHaveTextContent('Two stars, book early') + }) + + it('FE-MOB-CPLSH-008: links show their label, falling back to the bare host', () => { + setup() + const menu = screen.getByRole('link', { name: 'Menu' }) + expect(menu).toHaveAttribute('href', 'https://narisawa.example/menu') + expect(menu).toHaveAttribute('rel', 'noopener noreferrer') + expect(screen.getByRole('link', { name: 'booking.example' })).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-009: a malformed link url falls back to the raw string', () => { + setup({ place: place({ links: [{ url: 'not a url' }] }) }) + expect(screen.getByRole('link', { name: 'not a url' })).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-010: copy-to-trip, remove and close hand back to the screen', () => { + const { onCopyToTrip, onRemove, onClose } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Copy to trip' })) + expect(onCopyToTrip).toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Remove from list' })) + expect(onRemove).toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + }) + + it('FE-MOB-CPLSH-011: without delete rights the remove action is gone', () => { + setup({ canDelete: false }) + expect(screen.queryByRole('button', { name: 'Remove from list' })).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-012: the edit form is seeded from the place', () => { + setup() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument() + expect(screen.getByDisplayValue('Two stars, book early')).toBeInTheDocument() + expect(screen.getByDisplayValue('https://narisawa.example/menu')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Must see' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Rainy day' })).toHaveAttribute('aria-pressed', 'false') + // The read-only address disappears while editing. + expect(screen.queryByText('2-6-15 Minami-Aoyama')).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-013: saving sends the trimmed name, labels, category and normalised links', async () => { + const { onSave } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: ' Narisawa Tokyo ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rainy day' })) + fireEvent.change(screen.getByDisplayValue('https://www.booking.example/x'), { target: { value: 'booking.example/y' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ + name: 'Narisawa Tokyo', + description: 'Two stars, book early', + links: [ + { label: 'Menu', url: 'https://narisawa.example/menu' }, + { label: undefined, url: 'https://booking.example/y' }, + ], + category_id: 3, + label_ids: [11, 12], + })) + // Back to the read view. + await waitFor(() => expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()) + }) + + it('FE-MOB-CPLSH-014: an emptied name keeps the old one and a blank description clears it', async () => { + const { onSave } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: ' ' } }) + fireEvent.change(screen.getByDisplayValue('Two stars, book early'), { target: { value: ' ' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + name: 'Narisawa', + description: null, + }))) + }) + + it('FE-MOB-CPLSH-015: blank link rows are dropped before saving', async () => { + const { onSave } = setup({ place: place({ links: [{ url: ' ' }] }) }) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ links: [] }))) + }) + + it('FE-MOB-CPLSH-016: a failed save keeps the form open and shows the server message', async () => { + const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Name taken' } } }) + setup({ onSave }) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Name taken', 'error', undefined)) + expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-017: the save button is locked while the request runs', async () => { + let resolve!: () => void + const onSave = vi.fn().mockReturnValue(new Promise(r => { resolve = r })) + setup({ onSave }) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + + resolve() + await waitFor(() => expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()) + }) + + it('FE-MOB-CPLSH-018: cancel throws the edits away and returns to the read view', () => { + const { onSave } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + fireEvent.change(screen.getByDisplayValue('Narisawa'), { target: { value: 'Something else' } }) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onSave).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-019: a list without labels hides the label picker in edit mode', () => { + setup({ labels: [] }) + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.queryByRole('button', { name: 'Must see' })).not.toBeInTheDocument() + expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-020: the stored cover wins over the auto photo lookup', () => { + setup({ place: place({ image_url: '/uploads/places/n.jpg', google_place_id: 'gp1' }) }) + expect(document.querySelector('img')).toHaveAttribute('src', '/uploads/places/n.jpg') + expect(mapsApi.placePhoto).not.toHaveBeenCalled() + }) + + it('FE-MOB-CPLSH-021: without a cover the maps photo is fetched by place id', async () => { + vi.mocked(mapsApi.placePhoto).mockResolvedValue({ photoUrl: 'https://photos.example/n.jpg' }) + setup({ place: place({ google_place_id: 'gp1' }) }) + await waitFor(() => expect(document.querySelector('img')).toHaveAttribute('src', 'https://photos.example/n.jpg')) + expect(mapsApi.placePhoto).toHaveBeenCalledWith('gp1', undefined, undefined, 'Narisawa') + }) + + it('FE-MOB-CPLSH-022: coordinates stand in when there is no provider id', async () => { + setup({ place: place({ lat: 35.6, lng: 139.7 }) }) + await waitFor(() => expect(mapsApi.placePhoto).toHaveBeenCalledWith('35.6,139.7', 35.6, 139.7, 'Narisawa')) + // A null photoUrl leaves the gradient hero in place. + expect(document.querySelector('img')).toBeNull() + }) + + it('FE-MOB-CPLSH-023: a place with neither id nor coordinates never asks for a photo', () => { + setup() + expect(mapsApi.placePhoto).not.toHaveBeenCalled() + }) + + it('FE-MOB-CPLSH-024: a failing photo lookup is swallowed', async () => { + vi.mocked(mapsApi.placePhoto).mockRejectedValue(new Error('quota')) + setup({ place: place({ osm_id: 'node/1' }) }) + await waitFor(() => expect(mapsApi.placePhoto).toHaveBeenCalled()) + expect(document.querySelector('img')).toBeNull() + expect(screen.getByText('Narisawa')).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-025: the camera button needs both an upload handler and edit rights', () => { + const { rerender, props } = setup() + expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument() + + rerender() + expect(screen.queryByRole('button', { name: 'Upload image' })).not.toBeInTheDocument() + + rerender() + expect(screen.getByRole('button', { name: 'Upload image' })).toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-026: picking a file uploads it and clears the input again', async () => { + const onUploadImage = vi.fn<(f: File) => Promise>().mockResolvedValue(undefined) + setup({ onUploadImage }) + fireEvent.click(screen.getByRole('button', { name: 'Upload image' })) + + const input = document.querySelector('input[type="file"]') as HTMLInputElement + const file = pngFile() + fireEvent.change(input, { target: { files: [file] } }) + await waitFor(() => expect(onUploadImage).toHaveBeenCalledWith(file)) + expect(input.value).toBe('') + }) + + it('FE-MOB-CPLSH-027: an empty file pick is a no-op', () => { + const onUploadImage = vi.fn<(f: File) => Promise>().mockResolvedValue(undefined) + setup({ onUploadImage }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [] } }) + expect(onUploadImage).not.toHaveBeenCalled() + }) + + it('FE-MOB-CPLSH-028: a failed upload falls back to the generic upload error', async () => { + // No `error` field on the response — the sheet's own copy has to stand in. + const onUploadImage = vi.fn<(f: File) => Promise>().mockRejectedValue({ response: { data: {} } }) + setup({ onUploadImage }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [pngFile()] } }) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not upload image', 'error', undefined)) + }) + + it('FE-MOB-CPLSH-029: an existing cover can be dropped again', async () => { + const { onSave } = setup({ onUploadImage: vi.fn(), place: place({ image_url: '/uploads/places/n.jpg' }) }) + expect(screen.getByRole('button', { name: 'Change image' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Remove image' })) + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ image_url: null })) + }) + + it('FE-MOB-CPLSH-030: a failed cover removal reports the error', async () => { + const onSave = vi.fn().mockRejectedValue({ response: { data: { error: 'Locked' } } }) + setup({ onUploadImage: vi.fn(), onSave, place: place({ image_url: '/uploads/places/n.jpg' }) }) + fireEvent.click(screen.getByRole('button', { name: 'Remove image' })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Locked', 'error', undefined)) + }) + + it('FE-MOB-CPLSH-031: the ratings row only appears when voting is wired up', () => { + const onRate = vi.fn() + const { rerender, props } = setup() + expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument() + + rerender() + expect(screen.getByRole('radiogroup')).toBeInTheDocument() + fireEvent.click(screen.getByRole('radio', { name: '4' })) + expect(onRate).toHaveBeenCalledWith(4) + }) + + it('FE-MOB-CPLSH-032: opening a different place reseeds the form and leaves edit mode', () => { + const { rerender, props } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Edit' })) + expect(screen.getByDisplayValue('Narisawa')).toBeInTheDocument() + + rerender() + expect(screen.queryByDisplayValue('Narisawa')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument() + expect(screen.getByText('Den')).toBeInTheDocument() + expect(screen.queryByTestId('md')).not.toBeInTheDocument() + expect(screen.queryByRole('link')).not.toBeInTheDocument() + }) + + it('FE-MOB-CPLSH-033: closing holds the last place through the exit animation', () => { + const { rerender, props } = setup() + rerender() + expect(screen.getByText('Narisawa')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollShareSheet.test.tsx b/client/tests/unit/mobile/collections/MCollShareSheet.test.tsx new file mode 100644 index 000000000..0c3f5cf6a --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollShareSheet.test.tsx @@ -0,0 +1,270 @@ +import type { ComponentProps } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { CollectionMember } from '@trek/shared' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import MCollShareSheet from '../../../../src/mobile/screens/collections/MCollShareSheet' +import { collectionsApi } from '../../../../src/api/collections' +import { useCollectionStore } from '../../../../src/store/collectionStore' +import { useAuthStore } from '../../../../src/store/authStore' +import { buildUser } from '../../../helpers/factories' +import { resetAllStores, seedStore } from '../../../helpers/store' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CSHARE-001 to FE-MOB-CSHARE-021 + +type Props = ComponentProps + +const OWNER: CollectionMember = { user_id: 1, username: 'maurice', email: 'm@example.com', status: 'accepted', is_owner: true } +const EDITOR: CollectionMember = { user_id: 2, username: 'julien', email: 'j@example.com', status: 'accepted', role: 'editor', avatar: 'jul.png' } +const VIEWER: CollectionMember = { user_id: 4, username: 'ada', email: 'a@example.com', status: 'accepted', role: 'viewer' } +const PENDING: CollectionMember = { user_id: 3, username: 'zoe', email: 'z@example.com', status: 'pending' } + +// The store actions are the seam under test — the sheet only orchestrates them. +type CollectionState = ReturnType +const initialCollectionState = useCollectionStore.getState() +let actions: { + invite: Mock + cancelInvite: Mock + removeMember: Mock + setMemberRole: Mock + leave: Mock +} +type AddToast = NonNullable +let addToast: Mock + +function Harness(props: Omit) { + const { t } = useTranslation() + return +} + +function setup(over: Partial = {}) { + const onClose = vi.fn() + const onAfterLeave = vi.fn() + const props: Omit = { + open: true, + collectionId: 7, + collectionName: 'Tokyo 2026', + isOwner: true, + members: [PENDING, EDITOR, OWNER], + onClose, + onAfterLeave, + ...over, + } + const view = render() + return { ...view, onClose, onAfterLeave, props } +} + +describe('MCollShareSheet', () => { + beforeEach(() => { + resetAllStores() + useCollectionStore.setState(initialCollectionState, true) + actions = { + invite: vi.fn().mockResolvedValue(undefined), + cancelInvite: vi.fn().mockResolvedValue(undefined), + removeMember: vi.fn().mockResolvedValue(undefined), + setMemberRole: vi.fn().mockResolvedValue(undefined), + leave: vi.fn().mockResolvedValue(undefined), + } + useCollectionStore.setState(actions) + seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'maurice' }) }) + addToast = vi.fn() + window.__addToast = addToast + vi.spyOn(collectionsApi, 'availableUsers').mockResolvedValue({ users: [{ id: 9, username: 'nina' }, { id: 10, username: 'omar' }] }) + }) + + afterEach(() => { + vi.restoreAllMocks() + delete window.__addToast + }) + + it('FE-MOB-CSHARE-001: names the sheet after the list and counts the roster', () => { + setup() + expect(screen.getByRole('dialog', { name: 'Share list' })).toBeInTheDocument() + expect(screen.getByText('Share “Tokyo 2026”')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-002: sorts owner first, then accepted members, then pending invites', () => { + setup() + const names = screen.getAllByText(/^(maurice|julien|zoe)/).map(n => n.textContent) + expect(names).toEqual(['maurice (you)', 'julien', 'zoe']) + }) + + it('FE-MOB-CSHARE-003: marks the owner, tags the signed-in user and hides a pending email', () => { + setup() + expect(screen.getByText('OWNER')).toBeInTheDocument() + expect(screen.getByText('(you)')).toBeInTheDocument() + expect(screen.getByText('j@example.com')).toBeInTheDocument() + expect(screen.getByText('PENDING INVITE')).toBeInTheDocument() + expect(screen.queryByText('z@example.com')).not.toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-004: renders the uploaded avatar, otherwise the initial', () => { + setup() + // The avatar is decorative (alt=""), so it has no accessible role to query by. + const avatars = document.querySelectorAll('img') + expect(avatars).toHaveLength(1) + expect(avatars[0]).toHaveAttribute('src', '/uploads/avatars/jul.png') + expect(screen.getByText('M')).toBeInTheDocument() + expect(screen.getByText('Z')).toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-005: the owner cycles a member role viewer → editor → admin → viewer', async () => { + const { rerender, props } = setup() + fireEvent.click(screen.getByRole('button', { name: 'Role' })) + await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'admin')) + + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Role' })) + await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 2, 'viewer')) + + rerender() + fireEvent.click(screen.getByRole('button', { name: 'Role' })) + await waitFor(() => expect(actions.setMemberRole).toHaveBeenCalledWith(7, 4, 'editor')) + }) + + it('FE-MOB-CSHARE-006: without a collection id the role button is inert', () => { + setup({ collectionId: null }) + fireEvent.click(screen.getByRole('button', { name: 'Role' })) + expect(actions.setMemberRole).not.toHaveBeenCalled() + }) + + it('FE-MOB-CSHARE-007: the owner removes an accepted member and cancels a pending invite', async () => { + setup() + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(actions.removeMember).toHaveBeenCalledWith(7, 2)) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(actions.cancelInvite).toHaveBeenCalledWith(7, 3)) + }) + + it('FE-MOB-CSHARE-008: a failing member action surfaces the server message', async () => { + actions.removeMember.mockRejectedValue({ response: { data: { error: 'Not allowed' } } }) + setup() + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Not allowed', 'error', undefined)) + }) + + it('FE-MOB-CSHARE-009: only one member action runs at a time, and the row spins meanwhile', async () => { + let resolve!: () => void + actions.removeMember.mockReturnValue(new Promise(r => { resolve = r })) + setup() + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + expect(screen.getByRole('button', { name: 'Role' })).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(actions.cancelInvite).not.toHaveBeenCalled() + + resolve() + await waitFor(() => expect(screen.getByRole('button', { name: 'Role' })).not.toBeDisabled()) + }) + + it('FE-MOB-CSHARE-010: the owner picks a user and a role, then sends the invite', async () => { + setup() + expect(await screen.findByRole('button', { name: 'Send invite' })).toBeDisabled() + + fireEvent.click(screen.getByText('Select a user')) + fireEvent.click(screen.getByRole('button', { name: 'omar' })) + expect(screen.queryByRole('button', { name: 'nina' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Admin' })) + fireEvent.click(screen.getByRole('button', { name: 'Send invite' })) + + await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 10, 'admin')) + expect(addToast).toHaveBeenCalledWith('Invite sent', 'success', undefined) + // The picker falls back to its placeholder for the next invite. + await waitFor(() => expect(screen.getByText('Select a user')).toBeInTheDocument()) + }) + + it('FE-MOB-CSHARE-011: editor is the preselected invite role', async () => { + setup() + fireEvent.click(await screen.findByText('Select a user')) + fireEvent.click(screen.getByRole('button', { name: 'nina' })) + fireEvent.click(screen.getByRole('button', { name: 'Send invite' })) + await waitFor(() => expect(actions.invite).toHaveBeenCalledWith(7, 9, 'editor')) + }) + + it('FE-MOB-CSHARE-012: a failing invite keeps the selection and reports the error', async () => { + actions.invite.mockRejectedValue(new Error('boom')) + setup() + fireEvent.click(await screen.findByText('Select a user')) + fireEvent.click(screen.getByRole('button', { name: 'nina' })) + fireEvent.click(screen.getByRole('button', { name: 'Send invite' })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Could not send invite', 'error', undefined)) + expect(screen.getByText('nina')).toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-013: with nobody left to invite the owner is told so', async () => { + vi.mocked(collectionsApi.availableUsers).mockResolvedValue({ users: [] }) + setup() + expect(await screen.findByText('No users available to invite.')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Send invite' })).not.toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-014: a failing lookup degrades to the same empty state', async () => { + vi.mocked(collectionsApi.availableUsers).mockRejectedValue(new Error('offline')) + setup() + expect(await screen.findByText('No users available to invite.')).toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-015: members never see the invite form, only the read-only roster', async () => { + setup({ isOwner: false }) + expect(screen.getByText('Only the list owner can invite or remove people.')).toBeInTheDocument() + expect(screen.getByText('EDITOR')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Role' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument() + await waitFor(() => expect(collectionsApi.availableUsers).not.toHaveBeenCalled()) + }) + + it('FE-MOB-CSHARE-016: leaving asks for confirmation first and can be backed out of', () => { + setup({ isOwner: false }) + fireEvent.click(screen.getByRole('button', { name: 'Leave list' })) + expect(screen.getByText('Leave this shared list? You will lose access until you are invited again.')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(actions.leave).not.toHaveBeenCalled() + expect(screen.queryByText('Leave this shared list? You will lose access until you are invited again.')).not.toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-017: a confirmed leave calls the store and hands back to the screen', async () => { + const { onAfterLeave } = setup({ isOwner: false }) + fireEvent.click(screen.getByRole('button', { name: 'Leave list' })) + fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0]) + await waitFor(() => expect(actions.leave).toHaveBeenCalledWith(7)) + expect(addToast).toHaveBeenCalledWith('You left the list', 'success', undefined) + expect(onAfterLeave).toHaveBeenCalled() + }) + + it('FE-MOB-CSHARE-018: a failing leave reports the error and keeps the user in the list', async () => { + actions.leave.mockRejectedValue({ response: { data: { error: 'Owners cannot leave' } } }) + const { onAfterLeave } = setup({ isOwner: false }) + fireEvent.click(screen.getByRole('button', { name: 'Leave list' })) + fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0]) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Owners cannot leave', 'error', undefined)) + expect(onAfterLeave).not.toHaveBeenCalled() + }) + + it('FE-MOB-CSHARE-019: without a collection id neither invite nor leave reach the store', async () => { + setup({ collectionId: null, isOwner: false }) + fireEvent.click(screen.getByRole('button', { name: 'Leave list' })) + fireEvent.click(screen.getAllByRole('button', { name: /Leave list/ })[0]) + await waitFor(() => expect(actions.leave).not.toHaveBeenCalled()) + }) + + it('FE-MOB-CSHARE-020: closing resets the picker and the leave confirmation', async () => { + const { rerender, props, onClose } = setup() + fireEvent.click(await screen.findByText('Select a user')) + fireEvent.click(screen.getByRole('button', { name: 'nina' })) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + + rerender() + rerender() + expect(await screen.findByText('Select a user')).toBeInTheDocument() + }) + + it('FE-MOB-CSHARE-021: a closed sheet renders nothing and asks for no users', () => { + setup({ open: false }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(collectionsApi.availableUsers).not.toHaveBeenCalled() + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollSheetKit.test.tsx b/client/tests/unit/mobile/collections/MCollSheetKit.test.tsx new file mode 100644 index 000000000..6c1374157 --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollSheetKit.test.tsx @@ -0,0 +1,70 @@ +// FE-MOB-COLKIT-001 to FE-MOB-COLKIT-006 +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '../../../helpers/render' +import { + CancelPill, Eyebrow, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS, +} from '../../../../src/mobile/screens/collections/MCollSheetKit' + +describe('MCollSheetKit', () => { + it('FE-MOB-COLKIT-001: the sheet header shows the title and closes through its labelled button', () => { + const onClose = vi.fn() + render() + + expect(screen.getByText('Edit list')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLKIT-002: the eyebrow renders its children and merges an extra class', () => { + const { container } = render(Cover) + + const el = container.firstElementChild as HTMLElement + expect(el).toHaveTextContent('Cover') + expect(el.className).toContain('mt-4') + expect(el.className).toContain('text-m-faint') + }) + + it('FE-MOB-COLKIT-003: the cancel pill fires onClick and honours disabled', () => { + const onClick = vi.fn() + const { rerender } = render(Cancel) + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClick).toHaveBeenCalledTimes(1) + + rerender(Cancel) + const btn = screen.getByRole('button', { name: 'Cancel' }) + expect(btn).toBeDisabled() + fireEvent.click(btn) + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLKIT-004: the primary pill fires onClick, honours disabled and keeps the accent surface', () => { + const onClick = vi.fn() + const { rerender } = render(Save) + + const btn = screen.getByRole('button', { name: 'Save' }) + expect(btn.className).toContain('bg-m-act') + expect(btn.className).toContain('!bg-red-500') + fireEvent.click(btn) + expect(onClick).toHaveBeenCalledTimes(1) + + rerender(Save) + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('FE-MOB-COLKIT-005: a pill without onClick stays inert on click', () => { + render(Add) + // No handler wired — clicking must not throw. + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(screen.getByRole('button', { name: 'Add' })).toBeEnabled() + }) + + it('FE-MOB-COLKIT-006: the footer renders its children and the field classes use mobile tokens', () => { + render(footer slot) + expect(screen.getByText('footer slot')).toBeInTheDocument() + + // Both field presets have to sit on the sheet surface token, not a raw colour. + expect(INPUT_CLS).toContain('bg-[color:var(--m-sheet)]') + expect(TEXTAREA_CLS).toContain('bg-[color:var(--m-sheet)]') + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollTripPickerSheet.test.tsx b/client/tests/unit/mobile/collections/MCollTripPickerSheet.test.tsx new file mode 100644 index 000000000..64395d82f --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollTripPickerSheet.test.tsx @@ -0,0 +1,169 @@ +import type { ComponentProps } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { fireEvent, render, screen, waitFor } from '../../../helpers/render' +import MCollTripPickerSheet from '../../../../src/mobile/screens/collections/MCollTripPickerSheet' +import { tripsApi } from '../../../../src/api/client' +import { useTranslation } from '../../../../src/i18n' + +// FE-MOB-CTRIPP-001 to FE-MOB-CTRIPP-014 + +type Props = ComponentProps +type CopyResult = { copied: number; skipped: { id: number; name: string }[] } + +const TRIPS = [ + { id: 1, title: 'Japan 2020', start_date: '2020-03-01', end_date: '2020-03-05', cover_image: '/uploads/covers/jp.jpg' }, + { id: 2, title: 'Weekend in Prague', start_date: null, end_date: null, cover_image: null }, + { id: 3, title: 'Open ended', start_date: '2020-03-01', end_date: null, cover_image: null }, +] + +function Harness(props: Omit) { + const { t } = useTranslation() + return +} + +type AddToast = NonNullable +let addToast: Mock + +function setup(over: Partial = {}) { + const onCopy = vi.fn<(id: number) => Promise>().mockResolvedValue({ copied: 1, skipped: [] }) + const onClose = vi.fn() + const props = { open: true, count: 1, onCopy, onClose, ...over } + const view = render() + return { ...view, onCopy, onClose, props } +} + +describe('MCollTripPickerSheet', () => { + beforeEach(() => { + addToast = vi.fn() + window.__addToast = addToast + vi.spyOn(tripsApi, 'list').mockResolvedValue({ trips: TRIPS }) + }) + + afterEach(() => { + vi.restoreAllMocks() + delete window.__addToast + }) + + it('FE-MOB-CTRIPP-001: a single place uses the plain title, several the counted one', async () => { + const { rerender, props } = setup() + expect(await screen.findByText('Japan 2020')).toBeInTheDocument() + expect(screen.getByRole('dialog', { name: 'Copy to trip' })).toBeInTheDocument() + + rerender() + expect(screen.getByRole('dialog', { name: 'Copy 4 to trip' })).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-002: renders every trip with its cover and date range', async () => { + setup() + const row = await screen.findByRole('button', { name: /Japan 2020/ }) + expect(row.querySelector('img')).toHaveAttribute('src', '/uploads/covers/jp.jpg') + expect(screen.getByText('Sun, Mar 1, 2020 – Thu, Mar 5, 2020')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-003: a trip without dates shows no range, an open-ended one only its start', async () => { + setup() + const noDates = await screen.findByRole('button', { name: /Weekend in Prague/ }) + expect(noDates.querySelector('.lucide-calendar-days')).toBeNull() + // Without a cover the pin placeholder stands in. + expect(noDates.querySelector('.lucide-map-pin')).not.toBeNull() + expect(screen.getByText('Sun, Mar 1, 2020')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-004: the spinner holds the list until the trips arrive', async () => { + let resolve!: (v: { trips: typeof TRIPS }) => void + vi.mocked(tripsApi.list).mockReturnValue(new Promise(r => { resolve = r })) + setup() + expect(document.querySelector('.animate-spin')).not.toBeNull() + expect(screen.queryByText('No trips yet')).not.toBeInTheDocument() + + resolve({ trips: TRIPS }) + expect(await screen.findByText('Japan 2020')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-005: a response without a trips array falls back to the empty state', async () => { + vi.mocked(tripsApi.list).mockResolvedValue({}) + setup() + expect(await screen.findByText('No trips yet')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-006: a failing request shows the empty state instead of breaking', async () => { + vi.mocked(tripsApi.list).mockRejectedValue(new Error('offline')) + setup() + expect(await screen.findByText('No trips yet')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-007: a closed sheet asks for nothing and renders nothing', () => { + setup({ open: false }) + expect(tripsApi.list).not.toHaveBeenCalled() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-008: the search box filters the rows by title, case-insensitively', async () => { + setup() + await screen.findByText('Japan 2020') + fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'prague' } }) + expect(screen.getByText('Weekend in Prague')).toBeInTheDocument() + expect(screen.queryByText('Japan 2020')).not.toBeInTheDocument() + + fireEvent.change(screen.getByPlaceholderText('Search trips'), { target: { value: 'nothing' } }) + expect(screen.getByText('No trips yet')).toBeInTheDocument() + }) + + it('FE-MOB-CTRIPP-009: copying reports the count and closes the sheet', async () => { + const { onCopy, onClose } = setup() + fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ })) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + expect(onCopy).toHaveBeenCalledWith(1) + expect(addToast).toHaveBeenCalledWith('Copied 1 places', 'success', undefined) + }) + + it('FE-MOB-CTRIPP-010: duplicates the server skipped get their own toast', async () => { + const onCopy = vi.fn<(id: number) => Promise>() + .mockResolvedValue({ copied: 2, skipped: [{ id: 9, name: 'Louvre' }] }) + setup({ onCopy }) + fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Skipped 1 duplicates', 'info', undefined)) + expect(addToast).toHaveBeenCalledWith('Copied 2 places', 'success', undefined) + }) + + it('FE-MOB-CTRIPP-011: a no-op copy says so rather than staying silent', async () => { + const onCopy = vi.fn<(id: number) => Promise>().mockResolvedValue({ copied: 0, skipped: [] }) + setup({ onCopy }) + fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Nothing to copy', 'info', undefined)) + }) + + it('FE-MOB-CTRIPP-012: a failed copy surfaces the server message and keeps the sheet open', async () => { + const onCopy = vi.fn<(id: number) => Promise>() + .mockRejectedValue({ response: { data: { error: 'Trip is locked' } } }) + const { onClose } = setup({ onCopy }) + fireEvent.click(await screen.findByRole('button', { name: /Japan 2020/ })) + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Trip is locked', 'error', undefined)) + expect(onClose).not.toHaveBeenCalled() + }) + + it('FE-MOB-CTRIPP-013: while a copy runs the row spins and a second trip is ignored', async () => { + let resolve!: (v: CopyResult) => void + const onCopy = vi.fn<(id: number) => Promise>() + .mockReturnValue(new Promise(r => { resolve = r })) + const { onClose } = setup({ onCopy }) + const row = await screen.findByRole('button', { name: /Japan 2020/ }) + fireEvent.click(row) + expect(row).toBeDisabled() + expect(row.querySelector('.animate-spin')).not.toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /Weekend in Prague/ })) + expect(onCopy).toHaveBeenCalledTimes(1) + + resolve({ copied: 1, skipped: [] }) + await waitFor(() => expect(onClose).toHaveBeenCalled()) + }) + + it('FE-MOB-CTRIPP-014: the header close hands back without copying', async () => { + const { onClose, onCopy } = setup() + await screen.findByText('Japan 2020') + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalled() + expect(onCopy).not.toHaveBeenCalled() + }) +}) diff --git a/client/tests/unit/mobile/collections/MCollections.test.tsx b/client/tests/unit/mobile/collections/MCollections.test.tsx new file mode 100644 index 000000000..b6337137e --- /dev/null +++ b/client/tests/unit/mobile/collections/MCollections.test.tsx @@ -0,0 +1,618 @@ +// FE-MOB-COLSCR-001 to FE-MOB-COLSCR-031 +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '../../../helpers/render' +import MCollections from '../../../../src/mobile/screens/collections/MCollections' +import { ALL_SAVED } from '../../../../src/store/collectionStore' + +const mocks = vi.hoisted(() => ({ coll: {} as Record })) + +vi.mock('../../../../src/pages/collections/useCollections', () => ({ + useCollections: () => mocks.coll, +})) + +// Keep the Leaflet/MapLibre stack out of jsdom, but keep its contract. +vi.mock('../../../../src/components/Collections/CollectionMap', () => ({ + default: (props: { places: { id: number }[]; onOpenPlace: (id: number) => void; onDeselect: () => void; dark: boolean }) => ( +
+ {`markers:${props.places.length}`} + {`dark:${String(props.dark)}`} + + +
+ ), +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollPlaceSheet', () => ({ + default: (props: { + place: { id: number } | null + onSave: (patch: Record) => void + onUploadImage: (file: File) => void + onRate: (rating: number) => void + onCopyToTrip: () => void + onRemove: () => void + onSetStatus: (status: string) => void + onClose: () => void + }) => + props.place ? ( +
+ + + + + + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollAddSheet', () => ({ + default: (props: { open: boolean; collectionId: number | null; collectionName: string; lists: { id: number }[]; onClose: () => void; onAdded: () => void }) => + props.open ? ( +
+ {`add-target:${String(props.collectionId)}:${props.collectionName}:${props.lists.length}`} + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollShareSheet', () => ({ + default: (props: { open: boolean; collectionId: number | null; onClose: () => void; onAfterLeave: () => void }) => + props.open ? ( +
+ {`share:${String(props.collectionId)}`} + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollEditSheet', () => ({ + default: (props: { target: unknown; onClose: () => void; onCreated: (id: number) => void; onRequestDelete: (id: number) => void }) => + props.target ? ( +
+ {`editor:${typeof props.target === 'string' ? props.target : 'list'}`} + + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollLabelsSheet', () => ({ + default: (props: { + open: boolean + mode: string + selectedCount: number + onCreate: (name: string) => void + onUpdate: (id: number, body: Record) => void + onDelete: (id: number) => void + onAssign: (ids: number[]) => void + onSwitchToManage: () => void + onClose: () => void + }) => + props.open ? ( +
+ {`labels:${props.mode}:${props.selectedCount}`} + + + + + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollTripPickerSheet', () => ({ + default: (props: { open: boolean; count: number; onCopy: (id: number) => void; onClose: () => void }) => + props.open ? ( +
+ {`trippicker:${props.count}`} + + +
+ ) : null, +})) + +vi.mock('../../../../src/mobile/screens/collections/MCollListPickerSheet', () => ({ + default: (props: { mode: string | null; lists: { id: number; name: string }[]; count: number; onPick: (id: number) => void; onClose: () => void }) => + props.mode ? ( +
+ {`listpicker:${props.mode}:${props.count}:${props.lists.map(l => l.id).join(',')}`} + + +
+ ) : null, +})) + +const list = (id: number, name: string, over: Record = {}) => ({ + id, owner_id: 1, name, color: '#38BDF8', place_count: 2, is_owner: true, ...over, +}) + +const place = (id: number, name: string, over: Record = {}) => ({ + id, collection_id: 1, name, address: `${name} 1`, status: 'idea', ...over, +}) + +function makeHook(over: Record = {}): Record { + const noop = vi.fn() + return { + t: (key: string) => key, + language: 'en', + dark: false, + navigate: noop, + categories: [], + collections: [list(1, 'Hamburg')], + ownedLists: [list(1, 'Hamburg')], + sharedLists: [], + activeCollection: list(1, 'Hamburg'), + isAllSaved: false, + isOwner: true, + canEdit: true, + canDelete: true, + canShare: true, + activeId: 1, + places: [place(10, 'Elbphilharmonie')], + visiblePlaces: [place(10, 'Elbphilharmonie')], + mappable: [place(10, 'Elbphilharmonie', { lat: 53.5, lng: 10 })], + members: [], + incomingInvites: [], + counts: { all: 1, idea: 1, want: 0, visited: 0 }, + view: 'list', + statusFilter: 'all', + categoryFilter: 'all', + ratingFilter: 'all', + search: '', + selectedPlaceId: null, + selectMode: false, + selectedIds: [], + labels: [], + labelFilter: [], + labelOptions: [], + loading: false, + placesLoading: false, + setView: vi.fn(), setStatusFilter: vi.fn(), setCategoryFilter: vi.fn(), setRatingFilter: vi.fn(), + setLabelFilter: vi.fn(), setSearch: vi.fn(), setSelectedPlaceId: vi.fn(), setSelectMode: vi.fn(), + toggleSelect: vi.fn(), updatePlace: vi.fn(), uploadPlaceImage: vi.fn(), + showLabelManager: false, setShowLabelManager: vi.fn(), labelPickerOpen: false, setLabelPickerOpen: vi.fn(), + handleCreateLabel: vi.fn(), handleUpdateLabel: vi.fn(), handleDeleteLabel: vi.fn(), handleBulkAssignLabels: vi.fn(), + editorTarget: null, setEditorTarget: vi.fn(), handleEditorCreated: vi.fn(), + showAddPlace: false, setShowAddPlace: vi.fn(), handlePlaceAdded: vi.fn(), + confirmDeleteList: null, setConfirmDeleteList: vi.fn(), + showShare: false, setShowShare: vi.fn(), handleAfterLeave: vi.fn(), + selectedPlace: null, handleCloseDetail: vi.fn(), handleDetailStatus: vi.fn(), handleDetailRemove: vi.fn(), + copyIds: null, openCopyForSelectedPlace: vi.fn(), openCopyForSelection: vi.fn(), closeCopy: vi.fn(), handleCopyToTrip: vi.fn(), + handleSelectList: vi.fn(), handleDeleteList: vi.fn(), + handleStatusChange: vi.fn(), handleRatePlace: vi.fn(), handleDeleteSelected: vi.fn(), + allVisibleSelected: false, handleSelectAll: vi.fn(), + listPickerMode: null, setListPickerMode: vi.fn(), handleMoveToList: vi.fn(), handleDuplicateToList: vi.fn(), + ...over, + } +} + +const fn = (key: string) => mocks.coll[key] as ReturnType + +/** The list switcher is the first control in the header. */ +const switcher = () => screen.getAllByRole('button')[0] + +describe('MCollections', () => { + beforeEach(() => { + mocks.coll = makeHook() + }) + + it('FE-MOB-COLSCR-001: shows only the spinner while the first load is in flight', () => { + mocks.coll = makeHook({ loading: true, collections: [], places: [], visiblePlaces: [] }) + render() + + expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument() + expect(document.querySelector('.animate-spin')).not.toBeNull() + }) + + it('FE-MOB-COLSCR-002: with no lists at all it offers the first-list CTA', () => { + mocks.coll = makeHook({ collections: [], ownedLists: [], activeCollection: null, activeId: null, canShare: false }) + render() + + expect(screen.getByText('collections.empty.firstTitle')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /collections.newList/ })) + expect(fn('setEditorTarget')).toHaveBeenCalledWith('new') + expect(screen.queryByPlaceholderText('collections.search')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-003: the header shows the active list and its edit/share actions', () => { + render() + + expect(screen.getByText('Hamburg')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'collections.editListTitle' })) + expect(fn('setEditorTarget')).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })) + + fireEvent.click(screen.getByRole('button', { name: 'collections.share.title' })) + expect(fn('setShowShare')).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-COLSCR-004: the "All saved" union has no edit action and its own title', () => { + mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false }) + render() + + expect(screen.getByText('collections.allSaved')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'collections.editListTitle' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'collections.share.title' })).not.toBeInTheDocument() + // No per-list labels on the union. + expect(screen.queryByRole('button', { name: /collections.linkLabel/ })).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-005: without an active list the generic page title is used', () => { + mocks.coll = makeHook({ activeId: null, activeCollection: null, canShare: false }) + render() + expect(screen.getByText('collections.title')).toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-006: the switcher dropdown lists owned + shared lists and opens the editor', () => { + mocks.coll = makeHook({ + collections: [list(1, 'Hamburg'), list(2, 'Julien Tipps', { is_owner: false, color: null })], + ownedLists: [list(1, 'Hamburg')], + sharedLists: [list(2, 'Julien Tipps', { is_owner: false, color: null, place_count: undefined })], + }) + render() + + fireEvent.click(switcher()) + expect(screen.getByText('Julien Tipps')).toBeInTheDocument() + expect(screen.getByText('0')).toBeInTheDocument() + + fireEvent.click(screen.getByText('Julien Tipps')) + expect(fn('handleSelectList')).toHaveBeenCalledWith(2) + // Picking closes the dropdown again. + expect(screen.queryByText('Julien Tipps')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-007: the dropdown switches to the union and creates a new list', () => { + render() + + fireEvent.click(switcher()) + fireEvent.click(screen.getByRole('button', { name: /collections.allSaved/ })) + expect(fn('handleSelectList')).toHaveBeenCalledWith(ALL_SAVED) + + fireEvent.click(switcher()) + fireEvent.click(screen.getByRole('button', { name: /collections.newList/ })) + expect(fn('setEditorTarget')).toHaveBeenCalledWith('new') + }) + + it('FE-MOB-COLSCR-008: the view toggle and the search box drive the shared state', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'collections.view.map' })) + expect(fn('setView')).toHaveBeenCalledWith('map') + fireEvent.click(screen.getByRole('button', { name: 'collections.view.list' })) + expect(fn('setView')).toHaveBeenCalledWith('list') + expect(screen.getByRole('button', { name: 'collections.view.list' })).toHaveAttribute('aria-pressed', 'true') + + fireEvent.change(screen.getByPlaceholderText('collections.search'), { target: { value: 'elb' } }) + expect(fn('setSearch')).toHaveBeenCalledWith('elb') + }) + + it('FE-MOB-COLSCR-009: the status filter dropdown shows the counts and applies a status', () => { + // The union has no label chip, so the status chip is the only "all" chip. + mocks.coll = makeHook({ + isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, + counts: { all: 5, idea: 2, want: 2, visited: 1 }, + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' })) + expect(screen.getByText('5')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /collections.status.want/ })) + expect(fn('setStatusFilter')).toHaveBeenCalledWith('want') + expect(screen.queryByText('5')).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-010: an active status filter is reflected in the chip label', () => { + mocks.coll = makeHook({ + isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, statusFilter: 'visited', + }) + render() + + const chip = screen.getByRole('button', { name: 'collections.status.visited' }) + expect(chip.className).toContain('bg-m-act') + fireEvent.click(chip) + fireEvent.click(screen.getByRole('button', { name: /collections.status.filterAll/ })) + expect(fn('setStatusFilter')).toHaveBeenCalledWith('all') + }) + + it('FE-MOB-COLSCR-011: several active labels collapse to their count and can be toggled off or cleared', () => { + mocks.coll = makeHook({ + labelOptions: [ + { id: 1, collection_id: 1, name: 'Food', color: '#f00', count: 2 }, + { id: 2, collection_id: 1, name: 'Bar', color: null, count: 1 }, + ], + labelFilter: [1, 2], + statusFilter: 'want', + }) + render() + + // Two active labels → the chip shows how many, not their names. + fireEvent.click(screen.getByRole('button', { name: '2' })) + fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ })) + expect(fn('setLabelFilter')).toHaveBeenCalledWith([2]) + + // The "all" row wipes the filter and closes the panel. + fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' })) + expect(fn('setLabelFilter')).toHaveBeenLastCalledWith([]) + expect(screen.queryByRole('button', { name: /^Food\s*2$/ })).not.toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-012: a single active label shows its name and another one is added to the filter', () => { + mocks.coll = makeHook({ + labelOptions: [ + { id: 1, collection_id: 1, name: 'Food', color: null, count: 2 }, + { id: 2, collection_id: 1, name: 'Bar', color: '#0f0', count: 1 }, + ], + labelFilter: [2], + }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Bar' })) + fireEvent.click(screen.getByRole('button', { name: /^Food\s*2$/ })) + expect(fn('setLabelFilter')).toHaveBeenCalledWith([2, 1]) + }) + + it('FE-MOB-COLSCR-013: a list without labels says so, and the manage chip opens the manager', () => { + render() + + // Status chip and label chip both read "all" while nothing is filtered. + const chips = screen.getAllByRole('button', { name: 'collections.status.filterAll' }) + expect(chips).toHaveLength(2) + fireEvent.click(chips[1]) + expect(screen.getByText('collections.labels.empty')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /collections.linkLabel/ })) + expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-COLSCR-014: the backdrop under an open dropdown closes it again', () => { + mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false }) + render() + + fireEvent.click(screen.getByRole('button', { name: 'collections.status.filterAll' })) + // Chip plus the "all" row of the open panel. + expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'common.close' })) + expect(screen.getAllByRole('button', { name: /collections.status.filterAll/ })).toHaveLength(1) + }) + + it('FE-MOB-COLSCR-031: the select chip enters select mode', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /collections.select$/ })) + expect(fn('setSelectMode')).toHaveBeenCalledWith(true) + }) + + it('FE-MOB-COLSCR-015: select mode exposes the bulk actions and disables them without a selection', () => { + mocks.coll = makeHook({ selectMode: true, selectedIds: [] }) + render() + + expect(screen.getByRole('button', { name: /collections.copyToTrip/ })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: /collections.selectAll/ })) + expect(fn('handleSelectAll')).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(fn('setSelectMode')).toHaveBeenCalledWith(false) + }) + + it('FE-MOB-COLSCR-016: with a selection every bulk action reaches its handler', () => { + mocks.coll = makeHook({ + selectMode: true, + selectedIds: [10, 11], + allVisibleSelected: true, + labelOptions: [{ id: 1, collection_id: 1, name: 'Food', count: 1 }], + }) + render() + + expect(screen.getByRole('button', { name: /collections.deselectAll/ })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /collections.labels.assign/ })) + expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(true) + + fireEvent.click(screen.getByRole('button', { name: /collections.copyToTrip/ })) + expect(fn('openCopyForSelection')).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: /collections.moveToList/ })) + expect(fn('setListPickerMode')).toHaveBeenCalledWith('move') + + fireEvent.click(screen.getByRole('button', { name: /collections.duplicateToList/ })) + expect(fn('setListPickerMode')).toHaveBeenCalledWith('copy') + + fireEvent.click(screen.getByRole('button', { name: /common.delete/ })) + expect(fn('handleDeleteSelected')).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLSCR-017: a viewer without edit/delete rights loses move and delete', () => { + mocks.coll = makeHook({ selectMode: true, selectedIds: [10], canEdit: false, canDelete: false, isOwner: false }) + render() + + expect(screen.queryByRole('button', { name: /collections.moveToList/ })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /common.delete/ })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /collections.duplicateToList/ })).toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-018: the list view renders rows, the loading state and both empty states', () => { + render() + expect(screen.getByText('Elbphilharmonie')).toBeInTheDocument() + fireEvent.click(screen.getByText('Elbphilharmonie')) + expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10) + fireEvent.click(screen.getByRole('button', { name: 'collections.status.idea' })) + expect(fn('handleStatusChange')).toHaveBeenCalledWith(10, 'want') + + mocks.coll = makeHook({ places: [], visiblePlaces: [], placesLoading: true }) + const loadingView = render() + expect(loadingView.container.querySelector('.animate-spin')).not.toBeNull() + + mocks.coll = makeHook({ places: [], visiblePlaces: [] }) + render() + expect(screen.getByText('collections.empty.title')).toBeInTheDocument() + + mocks.coll = makeHook({ visiblePlaces: [] }) + render() + expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-019: the map view hands the mappable places to the map and reports clicks', () => { + mocks.coll = makeHook({ view: 'map', dark: true }) + render() + + expect(screen.getByText('markers:1')).toBeInTheDocument() + expect(screen.getByText('dark:true')).toBeInTheDocument() + // No status/label chips in map view. + expect(screen.queryByRole('button', { name: /collections.status.filterAll/ })).not.toBeInTheDocument() + + fireEvent.click(screen.getByText('map-open')) + expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(10) + fireEvent.click(screen.getByText('map-deselect')) + expect(fn('setSelectedPlaceId')).toHaveBeenCalledWith(null) + }) + + it('FE-MOB-COLSCR-020: a map view with nothing mappable falls back to the empty note', () => { + mocks.coll = makeHook({ view: 'map', mappable: [] }) + render() + + expect(screen.queryByTestId('map')).not.toBeInTheDocument() + expect(screen.getByText('collections.empty.noMatchTitle')).toBeInTheDocument() + }) + + it('FE-MOB-COLSCR-021: the place, add, share, label, trip and list sheets are wired to the hook', () => { + mocks.coll = makeHook({ + selectedPlace: place(10, 'Elbphilharmonie'), + selectedPlaceId: 10, + showAddPlace: true, + showShare: true, + labelPickerOpen: true, + selectedIds: [10, 11], + copyIds: [10], + listPickerMode: 'move', + ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')], + }) + render() + + fireEvent.click(screen.getByText('place-save')) + expect(fn('updatePlace')).toHaveBeenCalledWith(10, { notes: 'moin' }) + fireEvent.click(screen.getByText('place-upload')) + expect(fn('uploadPlaceImage')).toHaveBeenCalledWith(10, expect.any(File)) + fireEvent.click(screen.getByText('place-rate')) + expect(fn('handleRatePlace')).toHaveBeenCalledWith(10, 4) + fireEvent.click(screen.getByText('place-copy')) + expect(fn('openCopyForSelectedPlace')).toHaveBeenCalledTimes(1) + + // The add sheet gets the active list as its fixed target. + expect(screen.getByText('add-target:1:Hamburg:2')).toBeInTheDocument() + fireEvent.click(screen.getByText('add-close')) + expect(fn('setShowAddPlace')).toHaveBeenCalledWith(false) + + expect(screen.getByText('share:1')).toBeInTheDocument() + fireEvent.click(screen.getByText('share-close')) + expect(fn('setShowShare')).toHaveBeenCalledWith(false) + + // The picker is in assign mode while the label picker is open. + expect(screen.getByText('labels:assign:2')).toBeInTheDocument() + fireEvent.click(screen.getByText('labels-manage')) + expect(fn('setLabelPickerOpen')).toHaveBeenCalledWith(false) + expect(fn('setShowLabelManager')).toHaveBeenCalledWith(true) + fireEvent.click(screen.getByText('labels-close')) + expect(fn('setShowLabelManager')).toHaveBeenCalledWith(false) + + expect(screen.getByText('trippicker:1')).toBeInTheDocument() + fireEvent.click(screen.getByText('trip-copy')) + expect(fn('handleCopyToTrip')).toHaveBeenCalledWith(9) + + // The active list is not offered as a move target. + expect(screen.getByText('listpicker:move:2:2')).toBeInTheDocument() + fireEvent.click(screen.getByText('listpicker-pick')) + expect(fn('handleMoveToList')).toHaveBeenCalledWith(2) + fireEvent.click(screen.getByText('listpicker-close')) + expect(fn('setListPickerMode')).toHaveBeenCalledWith(null) + }) + + it('FE-MOB-COLSCR-022: the list picker duplicates in copy mode', () => { + mocks.coll = makeHook({ listPickerMode: 'copy', selectedIds: [10], ownedLists: [list(1, 'Hamburg'), list(2, 'Kopenhagen')] }) + render() + + fireEvent.click(screen.getByText('listpicker-pick')) + expect(fn('handleDuplicateToList')).toHaveBeenCalledWith(2) + expect(fn('handleMoveToList')).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLSCR-023: the editor sheet reports a created list and a delete request', () => { + mocks.coll = makeHook({ editorTarget: 'new' }) + render() + + expect(screen.getByText('editor:new')).toBeInTheDocument() + fireEvent.click(screen.getByText('editor-created')) + expect(fn('handleEditorCreated')).toHaveBeenCalledWith(77) + fireEvent.click(screen.getByText('editor-delete')) + expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(3) + fireEvent.click(screen.getByText('editor-close')) + expect(fn('setEditorTarget')).toHaveBeenCalledWith(null) + }) + + it('FE-MOB-COLSCR-024: the delete-list confirm cancels and deletes', () => { + mocks.coll = makeHook({ confirmDeleteList: 1 }) + render() + + expect(screen.getByRole('dialog', { name: 'collections.deleteList' })).toBeInTheDocument() + expect(screen.getByText('collections.deleteListConfirm')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'common.cancel' })) + expect(fn('setConfirmDeleteList')).toHaveBeenCalledWith(null) + + fireEvent.click(screen.getByRole('button', { name: /common.delete/ })) + expect(fn('handleDeleteList')).toHaveBeenCalledTimes(1) + + // Escape dismisses the sheet through the same handler. + fireEvent.keyDown(document, { key: 'Escape' }) + expect(fn('setConfirmDeleteList')).toHaveBeenCalledTimes(2) + }) + + it('FE-MOB-COLSCR-025: the ?create= hand-off from the bottom nav opens the add sheet once', async () => { + render(, { initialEntries: ['/collections?create=place'] }) + + await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true)) + // The param is consumed so a re-render does not re-open the sheet. + expect(fn('setShowAddPlace')).toHaveBeenCalledTimes(1) + }) + + it('FE-MOB-COLSCR-026: the union hand-off opens the sheet when at least one list can take the place', async () => { + mocks.coll = makeHook({ isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, canEdit: false }) + render(, { initialEntries: ['/collections?create=1'] }) + + await waitFor(() => expect(fn('setShowAddPlace')).toHaveBeenCalledWith(true)) + }) + + it('FE-MOB-COLSCR-027: without an editable target the hand-off is ignored', async () => { + mocks.coll = makeHook({ activeId: null, activeCollection: null, canEdit: false, canShare: false, ownedLists: [] }) + render(, { initialEntries: ['/collections?create=place'] }) + + await waitFor(() => expect(screen.getByText('collections.title')).toBeInTheDocument()) + expect(fn('setShowAddPlace')).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLSCR-028: an unrelated query param leaves the sheet closed', () => { + render(, { initialEntries: ['/collections?tab=map'] }) + expect(fn('setShowAddPlace')).not.toHaveBeenCalled() + }) + + it('FE-MOB-COLSCR-029: desktop-only category and rating filters are cleared on phone (#1435)', async () => { + mocks.coll = makeHook({ categoryFilter: 3, ratingFilter: 4 }) + render() + + await waitFor(() => expect(fn('setCategoryFilter')).toHaveBeenCalledWith('all')) + expect(fn('setRatingFilter')).toHaveBeenCalledWith('all') + }) + + it('FE-MOB-COLSCR-030: the add sheet has no fixed target while the union is active', () => { + mocks.coll = makeHook({ + isAllSaved: true, activeId: ALL_SAVED, activeCollection: null, canShare: false, showAddPlace: true, + }) + render() + + expect(screen.getByText('add-target:null::1')).toBeInTheDocument() + }) +}) diff --git a/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx b/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx new file mode 100644 index 000000000..8514c1557 --- /dev/null +++ b/client/tests/unit/mobile/dashboard/MDashWidgets.test.tsx @@ -0,0 +1,420 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useLocation } from 'react-router-dom'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { + resolveMobileDashOrder, useMobileDashOrder, useMobileDashVisibility, MobileDashWidget, +} from '../../../../src/mobile/screens/dashboard/MDashWidgets'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import type { UpcomingReservation } from '../../../../src/pages/dashboard/dashboardModel'; + +// FE-MOB-DWID-001 onwards + +const RATES = [ + { date: '2026-07-01', base: 'EUR', quote: 'USD', rate: 1.1 }, + { date: '2026-07-01', base: 'EUR', quote: 'CHF', rate: 0.95 }, +]; + +function fxHandler(body: unknown = RATES) { + server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.json(body))); +} + +function collectionsHandler(collections: unknown[]) { + server.use(http.get('/api/addons/collections', () => HttpResponse.json({ collections }))); +} + +function LocationEcho() { + const location = useLocation(); + return {location.pathname}; +} + +function enableCollectionsAddon(enabled: boolean) { + useAddonStore.setState({ + addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled }], + loaded: true, + } as never); +} + +beforeEach(() => { + resetAllStores(); + fxHandler(); + collectionsHandler([]); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('resolveMobileDashOrder', () => { + it('FE-MOB-DWID-001: falls back to the built-in order without a stored one', () => { + expect(resolveMobileDashOrder(undefined)).toEqual([ + 'trips', 'currency', 'collections', 'timezones', 'upcomingReservations', + ]); + }); + + it('FE-MOB-DWID-002: keeps the stored order and appends the missing blocks', () => { + expect(resolveMobileDashOrder(['timezones', 'trips'])).toEqual([ + 'timezones', 'trips', 'currency', 'collections', 'upcomingReservations', + ]); + }); + + it('FE-MOB-DWID-003: drops unknown and duplicated tokens', () => { + expect(resolveMobileDashOrder(['currency', 'nope', 'currency', 'trips'])).toEqual([ + 'currency', 'trips', 'collections', 'timezones', 'upcomingReservations', + ]); + }); +}); + +describe('mobile dashboard order + visibility hooks', () => { + it('FE-MOB-DWID-004: useMobileDashOrder reads the appearance blob', () => { + seedStore(useSettingsStore, { + settings: buildSettings({ + appearance: { dashboard: { mobileOrder: ['upcomingReservations', 'trips'] } }, + } as never), + }); + + const { result } = renderHook(() => useMobileDashOrder()); + + expect(result.current[0]).toBe('upcomingReservations'); + expect(result.current[1]).toBe('trips'); + }); + + it('FE-MOB-DWID-005: trips is always visible, widgets follow their flags', () => { + enableCollectionsAddon(true); + seedStore(useSettingsStore, { + settings: buildSettings({ + appearance: { dashboard: { mobile: { currency: false, collections: true, timezones: true, upcomingReservations: false } } }, + } as never), + }); + + const { result } = renderHook(() => useMobileDashVisibility()); + + expect(result.current).toEqual({ + trips: true, + currency: false, + collections: true, + timezones: true, + upcomingReservations: false, + }); + }); + + it('FE-MOB-DWID-006: collections stays hidden while the addon is off', () => { + enableCollectionsAddon(false); + + const { result } = renderHook(() => useMobileDashVisibility()); + + expect(result.current.collections).toBe(false); + expect(result.current.currency).toBe(true); + }); +}); + +describe('MobileDashWidget', () => { + it('FE-MOB-DWID-007: renders nothing for the trips block', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); + +describe('MCurrencyWidget', () => { + it('FE-MOB-DWID-008: converts the amount with the fetched rate', async () => { + render(); + + expect(screen.getByText('Currency')).toBeInTheDocument(); + await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument()); + // 100 (default amount) * 1.1 + expect(screen.getByText('110.00')).toBeInTheDocument(); + + fireEvent.change(screen.getByLabelText('From'), { target: { value: '250,5' } }); + expect(screen.getByText('275.55')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-009: a non-array response leaves the rate unavailable', async () => { + fxHandler({ error: 'nope' }); + render(); + + await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument()); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-010: a failed request leaves the rate unavailable', async () => { + server.use(http.get('https://api.frankfurter.dev/v2/rates', () => HttpResponse.error())); + render(); + + await waitFor(() => expect(screen.getByText('Rate unavailable')).toBeInTheDocument()); + }); + + it('FE-MOB-DWID-011: swapping writes the reversed pair to the settings store', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_fx_from: 'EUR', dashboard_fx_to: 'USD' }) }); + render(); + await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: 'Swap currencies' })); + + await waitFor(() => { + const s = useSettingsStore.getState().settings; + expect(s.dashboard_fx_from).toBe('USD'); + expect(s.dashboard_fx_to).toBe('EUR'); + }); + }); + + it('FE-MOB-DWID-012: the native picker writes the chosen currency', async () => { + render(); + await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument()); + + fireEvent.change(screen.getByLabelText('USD'), { target: { value: 'CHF' } }); + + await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_to).toBe('CHF')); + }); + + it('FE-MOB-DWID-013: refresh re-requests the rates', async () => { + let calls = 0; + server.use(http.get('https://api.frankfurter.dev/v2/rates', () => { calls++; return HttpResponse.json(RATES); })); + render(); + await waitFor(() => expect(calls).toBe(1)); + + fireEvent.click(screen.getByRole('button', { name: 'Refresh rates' })); + + await waitFor(() => expect(calls).toBe(2)); + }); + + it('FE-MOB-DWID-014: migrates the legacy fx keys into settings once', async () => { + localStorage.setItem('trek_fx_from', 'CAD'); + localStorage.setItem('trek_fx_to', 'CHF'); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => { + expect(localStorage.getItem('trek_fx_from')).toBeNull(); + expect(localStorage.getItem('trek_fx_to')).toBeNull(); + }); + expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD'); + }); + + it('FE-MOB-DWID-015: keeps the legacy fx keys when the write fails', async () => { + server.use( + http.put('/api/settings', () => new HttpResponse(null, { status: 500 })), + http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })), + ); + localStorage.setItem('trek_fx_from', 'CAD'); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => expect(useSettingsStore.getState().settings.dashboard_fx_from).toBe('CAD')); + expect(localStorage.getItem('trek_fx_from')).toBe('CAD'); + }); +}); + +describe('MCollectionsWidget', () => { + it('FE-MOB-DWID-016: shows the empty hint when there is no list', async () => { + render(); + + expect(await screen.findByText('No saved places yet')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-017: renders at most four lists with their place counts', async () => { + collectionsHandler([ + { id: 1, name: 'Tokyo eats', color: '#ff0000', place_count: 12 }, + { id: 2, name: 'Museums', color: null, place_count: 3 }, + { id: 3, name: 'Bars', color: null, place_count: null }, + { id: 4, name: 'Parks', color: null, place_count: 1 }, + { id: 5, name: 'Hidden', color: null, place_count: 9 }, + ]); + render(); + + expect(await screen.findByText('Tokyo eats')).toBeInTheDocument(); + expect(screen.getByText('Parks')).toBeInTheDocument(); + expect(screen.queryByText('Hidden')).not.toBeInTheDocument(); + // A list without a stored count renders a zero rather than blank. + expect(screen.getByText('0')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-032: the header arrow and a badge open the collections pages', async () => { + collectionsHandler([{ id: 7, name: 'Tokyo eats', color: '#f00', place_count: 2 }]); + render(<>); + + fireEvent.click(await screen.findByText('Tokyo eats')); + expect(screen.getByTestId('loc')).toHaveTextContent('/collections/7'); + + fireEvent.click(screen.getByRole('button', { name: 'Collections' })); + expect(screen.getByTestId('loc')).toHaveTextContent('/collections'); + }); + + it('FE-MOB-DWID-018: a failed fetch falls back to the empty hint', async () => { + server.use(http.get('/api/addons/collections', () => new HttpResponse(null, { status: 500 }))); + render(); + + expect(await screen.findByText('No saved places yet')).toBeInTheDocument(); + }); +}); + +describe('MTimezonesWidget', () => { + it('FE-MOB-DWID-019: renders the stored zones with their local time', () => { + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) }); + render(); + + expect(screen.getByText('Timezones')).toBeInTheDocument(); + expect(screen.getByText('Tokyo')).toBeInTheDocument(); + expect(screen.getByText(/^\d{2}:\d{2}$/)).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-020: an explicitly empty list shows the empty hint', () => { + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: [] }) }); + render(); + + expect(screen.getByText('No other timezones yet — add one with +')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-021: the edit mode adds and removes a zone', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add timezone' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: 'Europe/Berlin' } }); + + await waitFor(() => + expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo', 'Europe/Berlin'])); + + fireEvent.click(screen.getByRole('button', { name: 'Add timezone' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove Tokyo' })); + + await waitFor(() => + expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Europe/Berlin'])); + }); + + it('FE-MOB-DWID-022: picking an already-listed zone changes nothing', async () => { + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Add timezone' })); + fireEvent.change(screen.getByRole('combobox', { name: 'Add timezone' }), { target: { value: '' } }); + + // The picker closes again and the stored list is untouched. + await waitFor(() => expect(screen.queryByRole('combobox', { name: 'Add timezone' })).not.toBeInTheDocument()); + expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['Asia/Tokyo']); + }); + + it('FE-MOB-DWID-023: the clock ticks on its interval', () => { + vi.useFakeTimers(); + const nowSpy = vi.spyOn(Date.prototype, 'toLocaleTimeString'); + seedStore(useSettingsStore, { settings: buildSettings({ dashboard_timezones: ['Asia/Tokyo'] }) }); + render(); + const before = nowSpy.mock.calls.length; + + act(() => { vi.advanceTimersByTime(30000); }); + + expect(nowSpy.mock.calls.length).toBeGreaterThan(before); + nowSpy.mockRestore(); + }); + + it('FE-MOB-DWID-024: migrates the legacy timezone list into settings', async () => { + localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York'])); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull()); + expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York']); + }); + + it('FE-MOB-DWID-025: drops a malformed legacy timezone value', async () => { + localStorage.setItem('trek_dashboard_tz', 'not-json'); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull()); + expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined(); + }); + + it('FE-MOB-DWID-026: drops a legacy value that is not a list', async () => { + localStorage.setItem('trek_dashboard_tz', JSON.stringify({ tz: 'Asia/Tokyo' })); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => expect(localStorage.getItem('trek_dashboard_tz')).toBeNull()); + expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined(); + }); + + it('FE-MOB-DWID-027: keeps the legacy value when the write fails', async () => { + server.use( + http.put('/api/settings', () => new HttpResponse(null, { status: 500 })), + http.post('/api/settings/bulk', () => new HttpResponse(null, { status: 500 })), + ); + localStorage.setItem('trek_dashboard_tz', JSON.stringify(['America/New_York'])); + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + + await waitFor(() => + expect(useSettingsStore.getState().settings.dashboard_timezones).toEqual(['America/New_York'])); + expect(localStorage.getItem('trek_dashboard_tz')).toBe(JSON.stringify(['America/New_York'])); + }); +}); + +describe('legacy settings migration', () => { + it('FE-MOB-DWID-033: loaded settings without legacy keys write nothing', async () => { + seedStore(useSettingsStore, { settings: buildSettings(), isLoaded: true }); + + render(); + render(); + + await waitFor(() => expect(screen.getByText('1 EUR = 1.1000 USD')).toBeInTheDocument()); + expect(useSettingsStore.getState().settings.dashboard_fx_from).toBeUndefined(); + expect(useSettingsStore.getState().settings.dashboard_timezones).toBeUndefined(); + }); +}); + +describe('MUpcomingWidget', () => { + const res = (over: Partial = {}): UpcomingReservation => ({ + id: 1, trip_id: 7, title: 'teamLab Planets', type: 'ticket', + reservation_time: null, day_date: '2026-09-02', location: 'Toyosu', + ...over, + }); + + it('FE-MOB-DWID-028: shows the empty hint without reservations', () => { + render(); + + expect(screen.getByText('Upcoming reservations')).toBeInTheDocument(); + expect(screen.getByText('Nothing booked yet.')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-029: builds the subtitle from date, time and place', () => { + seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) }); + render( + , + ); + + expect(screen.getByText('Sep 3 · 19:30 · Narita')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-030: an unparseable date degrades to the remaining parts', () => { + render( + , + ); + + expect(screen.getByText('Japan')).toBeInTheDocument(); + }); + + it('FE-MOB-DWID-031: tapping a reservation opens its trip on the bookings tab', () => { + render(); + + fireEvent.click(screen.getByText('teamLab Planets')); + + expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen'); + }); +}); diff --git a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx index f4f6f5940..8f8f9fac2 100644 --- a/client/tests/unit/mobile/dashboard/MDashboard.test.tsx +++ b/client/tests/unit/mobile/dashboard/MDashboard.test.tsx @@ -1,19 +1,30 @@ import React from 'react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent } from '../../../helpers/render'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; import MDashboard from '../../../../src/mobile/screens/dashboard/MDashboard'; +import { tripsApi } from '../../../../src/api/client'; import { useAuthStore } from '../../../../src/store/authStore'; import { useInAppNotificationStore } from '../../../../src/store/inAppNotificationStore'; +import { usePluginStore } from '../../../../src/store/pluginStore'; +import type { TripCardBadge } from '../../../../src/api/client'; import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel'; // FE-MOB-DASH-001 onwards -const mocks = vi.hoisted(() => ({ dash: {} as Record })); +const mocks = vi.hoisted(() => ({ + dash: {} as Record, + badges: {} as Record, +})); vi.mock('../../../../src/pages/dashboard/useDashboard', () => ({ useDashboard: () => mocks.dash, })); +// The badge lookup is a network hook of its own; the screen only cares what it returns. +vi.mock('../../../../src/components/Plugins/TripCardBadges', () => ({ + useTripCardBadges: () => (tripId: number) => mocks.badges[tripId] ?? [], +})); + const iso = (offsetDays: number): string => { const d = new Date(); d.setDate(d.getDate() + offsetDays); @@ -66,6 +77,8 @@ function buildDash(over: Record = {}): Record describe('MDashboard', () => { beforeEach(() => { mocks.dash = buildDash(); + mocks.badges = {}; + usePluginStore.setState({ plugins: [], loaded: true }); useAuthStore.setState({ isAuthenticated: true, user: { id: 1, username: 'Maurice', email: 'maurice@trek.app', role: 'user', avatar_url: '' } as never, @@ -122,4 +135,408 @@ describe('MDashboard', () => { fireEvent.click(screen.getByText('teamLab Planets')); expect(sessionStorage.getItem('trip-tab-7')).toBe('buchungen'); }); + + it('FE-MOB-DASH-005: a failed load shows the retry banner', () => { + const retryLoad = vi.fn(); + mocks.dash = buildDash({ loadError: true, retryLoad }); + render(); + + expect(screen.getByRole('alert')).toHaveTextContent('dashboard.loadErrorBanner'); + fireEvent.click(screen.getByRole('button', { name: 'dashboard.retry' })); + expect(retryLoad).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-DASH-006: the empty state opens a blank create sheet', () => { + const setShowForm = vi.fn(); + const setEditingTrip = vi.fn(); + mocks.dash = buildDash({ setShowForm, setEditingTrip }); + render(); + + expect(screen.getByText('dashboard.emptyTitle')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'dashboard.emptyButton' })); + + expect(setEditingTrip).toHaveBeenCalledWith(null); + expect(setShowForm).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-DASH-007: a still-loading dashboard shows no empty state', () => { + mocks.dash = buildDash({ isLoading: true }); + render(); + + expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument(); + }); + + it('FE-MOB-DASH-008: demo mode adds the banner', () => { + mocks.dash = buildDash({ demoMode: true }); + render(); + + expect(screen.getByText(/demo/i)).toBeInTheDocument(); + }); + + it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => { + const navigate = vi.fn(); + mocks.dash = buildDash({ navigate }); + useInAppNotificationStore.setState({ unreadCount: 3, fetchUnreadCount: async () => {} }); + render(); + + const bell = screen.getByRole('button', { name: 'notifications.title' }); + expect(bell.querySelector('span[aria-hidden]')).toBeInTheDocument(); + + fireEvent.click(bell); + expect(navigate).toHaveBeenCalledWith('/notifications'); + }); + + it('FE-MOB-DASH-010: the avatar button toggles the user menu', () => { + render(); + const avatar = screen.getByRole('button', { name: 'nav.profile' }); + + expect(avatar).toHaveTextContent('M'); + expect(avatar).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(avatar); + expect(avatar).toHaveAttribute('aria-expanded', 'true'); + }); + + it('FE-MOB-DASH-011: a stored avatar replaces the initial', () => { + useAuthStore.setState({ + isAuthenticated: true, + user: { id: 1, username: 'Maurice', email: 'm@trek.app', role: 'user', avatar_url: '/uploads/avatars/m.jpg' } as never, + }); + render(); + + const avatar = screen.getByRole('button', { name: 'nav.profile' }); + expect(avatar.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg'); + }); + + it('FE-MOB-DASH-012: the calendar action opens the all-trips subscribe dialog', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' })); + + expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument(); + }); + + it('FE-MOB-DASH-013: the delete sheet names the trip and runs the confirm action', () => { + const confirmDelete = vi.fn(); + const setDeleteTrip = vi.fn(); + mocks.dash = buildDash({ deleteTrip: buildTrip({ title: 'Iceland' }), confirmDelete, setDeleteTrip }); + render(); + + const sheet = screen.getByRole('dialog', { name: 'common.delete' }); + expect(within(sheet).getByText('dashboard.confirm.delete')).toBeInTheDocument(); + + // The sheet's own chrome uses the real translations, the props the mocked t. + fireEvent.click(within(sheet).getByText('Cancel')); + expect(setDeleteTrip).toHaveBeenCalledWith(null); + + fireEvent.click(within(sheet).getAllByText('common.delete')[1]); + expect(confirmDelete).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-DASH-014: the copy sheet confirms the duplicate', () => { + const confirmCopy = vi.fn(); + mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), confirmCopy }); + render(); + + const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' }); + expect(within(sheet).getByText('Iceland')).toBeInTheDocument(); + + fireEvent.click(within(sheet).getByText('dashboard.confirm.copy.confirm')); + expect(confirmCopy).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-DASH-015: the list layout adds an archive action and the day/place stats', () => { + const handleArchive = vi.fn(); + mocks.dash = buildDash({ + viewMode: 'list', + handleArchive, + gridTrips: [buildTrip({ id: 4, title: 'Lisbon', day_count: 12, place_count: 30, shared_count: 1 })], + }); + render(); + + expect(screen.getByText('12')).toBeInTheDocument(); + expect(screen.getByText('30')).toBeInTheDocument(); + expect(screen.getByText('dashboard.card.buddyOne')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'dashboard.archive' })); + expect(handleArchive).toHaveBeenCalledWith(4); + }); + + it('FE-MOB-DASH-016: an undated trip renders dashes instead of a range', () => { + mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 5, start_date: null, end_date: null })] }); + render(); + + const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement; + expect(within(card).getAllByText('—')).toHaveLength(2); + expect(within(card).getByText('dashboard.card.idea')).toBeInTheDocument(); + }); + + it.each([ + [{ is_archived: 1 }, 'dashboard.archived'], + [{ start_date: iso(0), end_date: null }, 'dashboard.status.today'], + [{ start_date: iso(1), end_date: null }, 'dashboard.status.tomorrow'], + [{ start_date: iso(10), end_date: null }, 'dashboard.mobile.inDays'], + [{ start_date: iso(200), end_date: null }, 'dashboard.mobile.inMonths'], + [{ start_date: iso(-20), end_date: iso(-10) }, 'dashboard.mobile.completed'], + ] as Array<[Partial, string]>)( + 'FE-MOB-DASH-017: the card badge follows the trip state (%#)', + (over, label) => { + mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 6, ...over })] }); + render(); + + // The filter segments reuse some of these labels, so assert on the card itself. + const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement; + expect(within(card).getByText(label)).toBeInTheDocument(); + }, + ); + + it('FE-MOB-DASH-018: plugin badges render as chips, linked ones as anchors', () => { + mocks.badges = { + 8: [ + { pluginId: 'p', id: 'a', label: 'Weather', value: '21°', tone: 'success' }, + { pluginId: 'p', id: 'b', label: 'Docs', value: '', tone: 'warn', url: 'https://example.com' }, + ] as TripCardBadge[], + }; + mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 8, title: 'Oslo' })] }); + render(); + + expect(screen.getByText('21°')).toBeInTheDocument(); + const link = screen.getByText('Docs').closest('a') as HTMLAnchorElement; + expect(link).toHaveAttribute('href', 'https://example.com'); + // The chip must not open the card underneath it. + fireEvent.click(link); + expect((mocks.dash.navigate as ReturnType)).not.toHaveBeenCalled(); + }); + + it('FE-MOB-DASH-019: dashboard widget plugins render below the blocks', () => { + usePluginStore.setState({ + plugins: [ + { id: 'w1', name: 'Packing helper', type: 'widget', icon: null }, + { id: 'h1', name: 'Hero widget', type: 'widget', icon: null, slot: 'hero' }, + ] as never, + loaded: true, + }); + mocks.dash = buildDash({ spotlight: buildTrip() }); + render(); + + expect(screen.getByText('Packing helper')).toBeInTheDocument(); + expect(screen.queryByText('Hero widget')).not.toBeInTheDocument(); + }); + + it('FE-MOB-DASH-020: a card action does not open the trip underneath it', () => { + const navigate = vi.fn(); + const setCopyTrip = vi.fn(); + mocks.dash = buildDash({ navigate, setCopyTrip, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' })); + + expect(setCopyTrip).toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('FE-MOB-DASH-021: pressing Enter on a card opens the trip', () => { + const navigate = vi.fn(); + mocks.dash = buildDash({ navigate, gridTrips: [buildTrip({ id: 9, title: 'Oslo' })] }); + render(); + + fireEvent.keyDown(screen.getByText('Oslo').closest('[role="button"]') as HTMLElement, { key: 'Enter' }); + + expect(navigate).toHaveBeenCalledWith('/trips/9'); + }); + + it('FE-MOB-DASH-022: an upcoming spotlight counts down instead of showing progress', () => { + mocks.dash = buildDash({ + spotlight: buildTrip({ start_date: iso(4), end_date: iso(9), cover_image: '/uploads/covers/x.jpg' }), + }); + const { container } = render(); + + expect(screen.getByText('dashboard.hero.badgeNext')).toBeInTheDocument(); + expect(screen.getByText('dashboard.mobile.inDays')).toBeInTheDocument(); + expect(container.querySelector('img[src="/uploads/covers/x.jpg"]')).toBeInTheDocument(); + }); + + it('FE-MOB-DASH-023: a finished spotlight is labelled as recent', () => { + mocks.dash = buildDash({ spotlight: buildTrip({ start_date: iso(-20), end_date: iso(-10), day_count: 1 }) }); + render(); + + expect(screen.getByText('dashboard.hero.badgeRecent')).toBeInTheDocument(); + expect(screen.getByText('dashboard.mobile.spotlightDayOne')).toBeInTheDocument(); + }); + + it('FE-MOB-DASH-024: the spotlight opens on Enter as well as on click', () => { + const navigate = vi.fn(); + mocks.dash = buildDash({ navigate, spotlight: buildTrip({ id: 12 }) }); + render(); + + fireEvent.keyDown(screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement, { key: 'Enter' }); + + expect(navigate).toHaveBeenCalledWith('/trips/12'); + }); + + it('FE-MOB-DASH-025: editing a card hands the trip to the create/edit sheet', () => { + const setEditingTrip = vi.fn(); + const setShowForm = vi.fn(); + const trip = buildTrip({ id: 13, title: 'Oslo' }); + mocks.dash = buildDash({ setEditingTrip, setShowForm, gridTrips: [trip] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'common.edit' })); + + expect(setEditingTrip).toHaveBeenCalledWith(trip); + expect(setShowForm).toHaveBeenCalledWith(true); + }); + + it('FE-MOB-DASH-026: the mobile widgets render in the configured block order', () => { + mocks.dash = buildDash(); + render(); + + // The widget panels bring their own translations rather than the mocked hook's t. + expect(screen.getByText('Currency')).toBeInTheDocument(); + expect(screen.getByText('Timezones')).toBeInTheDocument(); + expect(screen.getByText('Upcoming reservations')).toBeInTheDocument(); + }); + + it('FE-MOB-DASH-027: a malformed date renders a dash instead of crashing', () => { + mocks.dash = buildDash({ gridTrips: [buildTrip({ id: 14, start_date: 'not-a-date', end_date: iso(3) })] }); + render(); + + const card = screen.getByText('Japan 2026').closest('[role="button"]') as HTMLElement; + expect(within(card).getAllByText('—')).toHaveLength(1); + }); + + it('FE-MOB-DASH-028: the archived card duplicates and deletes through the hook', () => { + const setCopyTrip = vi.fn(); + const setDeleteTrip = vi.fn(); + const trip = buildTrip({ id: 15, title: 'Iceland', is_archived: 1 }); + mocks.dash = buildDash({ tripFilter: 'archive', setCopyTrip, setDeleteTrip, gridTrips: [trip] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'dashboard.aria.duplicate' })); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + + expect(setCopyTrip).toHaveBeenCalledWith(trip); + expect(setDeleteTrip).toHaveBeenCalledWith(trip); + }); + + it('FE-MOB-DASH-029: a grid card deletes through the hook', () => { + const setDeleteTrip = vi.fn(); + const trip = buildTrip({ id: 16, title: 'Oslo' }); + mocks.dash = buildDash({ setDeleteTrip, gridTrips: [trip] }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + + expect(setDeleteTrip).toHaveBeenCalledWith(trip); + }); + + it('FE-MOB-DASH-030: a list card opens its trip by click and by Enter', () => { + const navigate = vi.fn(); + mocks.dash = buildDash({ viewMode: 'list', navigate, gridTrips: [buildTrip({ id: 17, title: 'Oslo' })] }); + render(); + const card = screen.getByText('Oslo').closest('[role="button"]') as HTMLElement; + + fireEvent.click(card); + fireEvent.keyDown(card, { key: 'Enter' }); + + expect(navigate).toHaveBeenNthCalledWith(1, '/trips/17'); + expect(navigate).toHaveBeenNthCalledWith(2, '/trips/17'); + }); + + it('FE-MOB-DASH-031: the brand tile scrolls the shell content back to the top', () => { + // The screen scrolls inside the shell's container, not the window. + const scroller = document.createElement('div'); + scroller.className = 'overflow-y-auto'; + const scrollTo = vi.fn(); + scroller.scrollTo = scrollTo; + document.body.appendChild(scroller); + render(, { container: scroller }); + + fireEvent.click(screen.getByRole('button', { name: 'TREK' })); + + expect(scrollTo).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' }); + scroller.remove(); + }); + + it('FE-MOB-DASH-032: the user menu backdrop closes it again', () => { + const { container } = render(); + const avatar = screen.getByRole('button', { name: 'nav.profile' }); + + fireEvent.click(avatar); + fireEvent.click(container.querySelector('.fixed.inset-0') as HTMLElement); + + expect(avatar).toHaveAttribute('aria-expanded', 'false'); + }); + + it('FE-MOB-DASH-033: closing the trip sheet resets the form state', () => { + const setShowForm = vi.fn(); + const setEditingTrip = vi.fn(); + mocks.dash = buildDash({ showForm: true, setShowForm, setEditingTrip }); + render(); + + fireEvent.click(within(screen.getByRole('dialog')).getByText('Cancel')); + + expect(setShowForm).toHaveBeenCalledWith(false); + expect(setEditingTrip).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-DASH-034: archiving from the edit sheet runs the matching hook action', () => { + const handleArchive = vi.fn(); + const handleUnarchive = vi.fn(); + mocks.dash = buildDash({ + showForm: true, editingTrip: buildTrip({ id: 18 }), handleArchive, handleUnarchive, + }); + render(); + + fireEvent.click(screen.getByText('Archive')); + expect(handleArchive).toHaveBeenCalledWith(18); + + mocks.dash = buildDash({ + showForm: true, editingTrip: buildTrip({ id: 18, is_archived: 1 }), handleArchive, handleUnarchive, + }); + render(); + + fireEvent.click(screen.getByText('Restore')); + expect(handleUnarchive).toHaveBeenCalledWith(18); + }); + + it('FE-MOB-DASH-035: a cover uploaded in the sheet is patched into the trip list', async () => { + const setTrips = vi.fn((updater: (prev: DashboardTrip[]) => DashboardTrip[]) => { + expect(updater([buildTrip({ id: 19, cover_image: null })])[0].cover_image).toBe('/uploads/covers/n.jpg'); + }); + vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/n.jpg' }); + mocks.dash = buildDash({ showForm: true, editingTrip: buildTrip({ id: 19 }), setTrips }); + render(); + + fireEvent.change(document.querySelector('input[type="file"]') as HTMLInputElement, { + target: { files: [new File(['x'], 'c.png', { type: 'image/png' })] }, + }); + + await waitFor(() => expect(setTrips).toHaveBeenCalled()); + }); + + it('FE-MOB-DASH-036: dismissing the copy sheet clears the pending trip', () => { + const setCopyTrip = vi.fn(); + mocks.dash = buildDash({ copyTrip: buildTrip({ title: 'Iceland' }), setCopyTrip }); + render(); + + const sheet = screen.getByRole('dialog', { name: 'dashboard.confirm.copy.title' }); + fireEvent.click(within(sheet).getByText('Cancel')); + + expect(setCopyTrip).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-DASH-037: the subscribe dialog can be closed again', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'dashboard.subscribeAllTrips' })); + expect(screen.getByText('dashboard.subscribeAllTripsDesc')).toBeInTheDocument(); + + // The dialog's close control is icon-only; it is the first button of the card. + const card = screen.getByText('dashboard.subscribeAllTripsDesc').parentElement as HTMLElement; + fireEvent.click(card.querySelector('button') as HTMLElement); + + await waitFor(() => + expect(screen.queryByText('dashboard.subscribeAllTripsDesc')).not.toBeInTheDocument()); + }); }); diff --git a/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx new file mode 100644 index 000000000..5639d0421 --- /dev/null +++ b/client/tests/unit/mobile/dashboard/MNewTripSheet.test.tsx @@ -0,0 +1,510 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '../../../helpers/render'; +import MNewTripSheet from '../../../../src/mobile/screens/dashboard/MNewTripSheet'; +import { tripsApi } from '../../../../src/api/client'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { usePermissionsStore } from '../../../../src/store/permissionsStore'; +import { buildUser } from '../../../helpers/factories'; +import type { DashboardTrip } from '../../../../src/pages/dashboard/dashboardModel'; +import type { Trip, TripCreateRequest } from '@trek/shared'; + +interface CoverSearchPhoto { + id: string + url: string + thumb: string + description?: string | null + photographer?: string | null +} + +// FE-MOB-NTSH-001 onwards + +vi.mock('../../../../src/utils/convertHeic', () => ({ + normalizeImageFile: (file: File) => Promise.resolve(file), +})); + +// The real pickers own a lot of unrelated calendar/portal behaviour; plain +// controls keep this suite on the sheet's own logic. +vi.mock('../../../../src/components/shared/CustomDateTimePicker', () => ({ + CustomDatePicker: ({ value, onChange, placeholder }: { + value: string; onChange: (v: string) => void; placeholder: string + }) => onChange(e.target.value)} />, +})); + +vi.mock('../../../../src/components/shared/CustomSelect', () => ({ + default: ({ value, onChange, disabled }: { + value: string; onChange: (v: string) => void; disabled?: boolean + }) => ( + + ), +})); + +const origCreateObjectURL = URL.createObjectURL; + +function buildDashTrip(over: Partial = {}): DashboardTrip { + return { + id: 42, user_id: 1, title: 'Japan 2026', currency: 'EUR', is_archived: 0, + start_date: '2026-05-01', end_date: '2026-05-08', + description: 'cherry blossoms', cover_image: '/uploads/covers/a.jpg', + ...over, + } as unknown as DashboardTrip; +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +const png = () => new File(['x'], 'cover.png', { type: 'image/png' }); + +let toastCalls: Array<[string, string | undefined]>; + +beforeEach(() => { + toastCalls = []; + window.__addToast = ((message: string, type?: string) => { + toastCalls.push([message, type]); + return 1; + }) as unknown as typeof window.__addToast; + URL.createObjectURL = (() => 'blob:preview') as unknown as typeof URL.createObjectURL; + useAuthStore.setState({ isAuthenticated: true, user: buildUser({ id: 1, role: 'user' }) }); + usePermissionsStore.setState({ permissions: {} }); +}); + +afterEach(() => { + URL.createObjectURL = origCreateObjectURL; + delete window.__addToast; + vi.restoreAllMocks(); +}); + +describe('MNewTripSheet', () => { + it('FE-MOB-NTSH-001: create mode shows the empty form plus the no-date hint', () => { + render( {}} onSave={() => {}} />); + + expect(screen.getByRole('dialog', { name: 'Create New Trip' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('e.g. Summer in Japan')).toHaveValue(''); + expect(screen.getByText(/7 default days will be created/)).toBeInTheDocument(); + expect(screen.getByText('Add cover image')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-002: edit mode preloads the trip and hides the no-date hint', () => { + render( {}} onSave={() => {}} />); + + expect(screen.getByRole('dialog', { name: 'Edit Trip' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('e.g. Summer in Japan')).toHaveValue('Japan 2026'); + expect(screen.getByPlaceholderText('What is this trip about?')).toHaveValue('cherry blossoms'); + expect(screen.getByLabelText('Start Date')).toHaveValue('2026-05-01'); + expect(screen.queryByText(/7 default days will be created/)).not.toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-003: an empty title blocks the save', () => { + const onSave = vi.fn(async () => undefined); + render( {}} onSave={onSave} />); + + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + expect(screen.getByText('Title is required')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-004: an end date before the start blocks the save', () => { + const onSave = vi.fn(async () => undefined); + render( {}} onSave={onSave} />); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-10' } }); + fireEvent.change(screen.getByLabelText('End Date'), { target: { value: '2026-06-01' } }); + + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + expect(screen.getByText('End date must be after start date')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-005: a dateless create asks for the 7 default days and closes', async () => { + const onSave = vi.fn(async (_data: TripCreateRequest) => undefined); + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: ' Iceland ' } }); + fireEvent.change(screen.getByPlaceholderText('What is this trip about?'), { target: { value: ' ring road ' } }); + fireEvent.change(screen.getByLabelText('currency'), { target: { value: 'USD' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ + title: 'Iceland', + description: 'ring road', + start_date: null, + end_date: null, + currency: 'USD', + day_count: 7, + })); + expect(onClose).toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-006: moving the start keeps the trip length', () => { + render( {}} onSave={() => {}} />); + + // 01.–08.05. is a 7-day span; shifting the start by a month keeps it. + fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-01' } }); + + expect(screen.getByLabelText('End Date')).toHaveValue('2026-06-08'); + }); + + it('FE-MOB-NTSH-007: a start without an end pulls the end date along', () => { + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByLabelText('Start Date'), { target: { value: '2026-06-01' } }); + + expect(screen.getByLabelText('End Date')).toHaveValue('2026-06-01'); + }); + + it('FE-MOB-NTSH-008: a rejected save surfaces the error and keeps the sheet open', async () => { + const onSave = vi.fn(async () => { throw new Error('Title already used'); }); + const onClose = vi.fn(); + render(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + expect(await screen.findByText('Title already used')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-009: create mode holds the picked file back until the trip exists', async () => { + const upload = vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/new.jpg' }); + const onCoverUpdate = vi.fn(); + const onSave = vi.fn(async () => ({ trip: { id: 77 } as Trip })); + render( {}} onSave={onSave} onCoverUpdate={onCoverUpdate} />); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()); + // Nothing is uploaded before the trip is created. + expect(upload).not.toHaveBeenCalled(); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + await waitFor(() => expect(upload).toHaveBeenCalledWith(77, expect.any(FormData))); + expect(onCoverUpdate).toHaveBeenCalledWith(77, '/uploads/covers/new.jpg'); + }); + + it('FE-MOB-NTSH-010: a failed post-create cover upload only toasts', async () => { + vi.spyOn(tripsApi, 'uploadCover').mockRejectedValue(new Error('nope')); + const onClose = vi.fn(); + render( ({ trip: { id: 77 } as Trip })} />); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + await waitFor(() => expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()); + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + await waitFor(() => expect(toastCalls).toContainEqual(['Failed to upload', 'error'])); + expect(onClose).toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-011: an ignored file change is a no-op', async () => { + const upload = vi.spyOn(tripsApi, 'uploadCover'); + render( {}} onSave={() => {}} />); + + fireEvent.change(fileInput(), { target: { files: [] } }); + + await waitFor(() => expect(upload).not.toHaveBeenCalled()); + expect(screen.getByText('Add cover image')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-012: edit mode uploads the cover straight away', async () => { + const upload = vi.spyOn(tripsApi, 'uploadCover').mockResolvedValue({ cover_image: '/uploads/covers/next.jpg' }); + const onCoverUpdate = vi.fn(); + render( {}} onSave={() => {}} onCoverUpdate={onCoverUpdate} />); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + + await waitFor(() => expect(upload).toHaveBeenCalledWith(42, expect.any(FormData))); + expect(onCoverUpdate).toHaveBeenCalledWith(42, '/uploads/covers/next.jpg'); + expect(toastCalls).toContainEqual(['Cover image saved', 'success']); + }); + + it('FE-MOB-NTSH-013: a failed edit-mode upload toasts and keeps the old cover', async () => { + vi.spyOn(tripsApi, 'uploadCover').mockRejectedValue(new Error('boom')); + render( {}} onSave={() => {}} />); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + + await waitFor(() => expect(toastCalls).toContainEqual(['Failed to upload', 'error'])); + expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-014: searching with neither a query nor a title asks for a term', async () => { + const search = vi.spyOn(tripsApi, 'searchCoverImages'); + render( {}} onSave={() => {}} />); + + fireEvent.keyDown(screen.getByPlaceholderText('Search destination photos'), { key: 'Enter' }); + + expect(await screen.findByText('Enter a search term')).toBeInTheDocument(); + expect(search).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-015: an empty result set is reported as "no images"', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ photos: [] }); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'reykjavik' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + + expect(await screen.findByText('No images found')).toBeInTheDocument(); + expect(tripsApi.searchCoverImages).toHaveBeenCalledWith('reykjavik'); + }); + + it('FE-MOB-NTSH-016: a failing search shows the API error message', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockRejectedValue({ response: { data: { error: 'Unsplash key missing' } } }); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + + expect(await screen.findByText('Unsplash key missing')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-017: create mode previews the picked Unsplash photo without a request', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }], + }); + const update = vi.spyOn(tripsApi, 'update'); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + + const pick = await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' }); + expect(screen.getByText('Ada')).toBeInTheDocument(); + fireEvent.click(pick); + + await waitFor(() => expect(document.querySelector('img[src="https://img/full.jpg"]')).toBeInTheDocument()); + expect(update).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-018: a pending Unsplash pick is written to the freshly created trip', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: null }], + }); + const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 77 } as Trip }); + const onCoverUpdate = vi.fn(); + render( + {}} + onSave={async () => ({ trip: { id: 77 } as Trip })} + onCoverUpdate={onCoverUpdate} + />, + ); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Unsplash' })); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + await waitFor(() => expect(update).toHaveBeenCalledWith(77, { cover_image: 'https://img/full.jpg' })); + expect(onCoverUpdate).toHaveBeenCalledWith(77, 'https://img/full.jpg'); + }); + + it('FE-MOB-NTSH-019: a failed cover write after create only toasts', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: null }], + }); + vi.spyOn(tripsApi, 'update').mockRejectedValue(new Error('offline')); + render( {}} onSave={async () => ({ trip: { id: 77 } as Trip })} />); + + fireEvent.change(screen.getByPlaceholderText('e.g. Summer in Japan'), { target: { value: 'Iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Unsplash' })); + fireEvent.click(screen.getByRole('button', { name: 'Create New Trip' })); + + await waitFor(() => expect(toastCalls).toContainEqual(['Failed to save cover image', 'error'])); + }); + + it('FE-MOB-NTSH-020: edit mode persists an Unsplash pick immediately', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }], + }); + const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 42 } as Trip }); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' })); + + await waitFor(() => expect(update).toHaveBeenCalledWith(42, { cover_image: 'https://img/full.jpg' })); + expect(toastCalls).toContainEqual(['Cover image saved', 'success']); + }); + + it('FE-MOB-NTSH-021: a failed edit-mode Unsplash pick toasts the API error', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: 'https://img/full.jpg', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }], + }); + vi.spyOn(tripsApi, 'update').mockRejectedValue({ response: { data: { error: 'Storage full' } } }); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' })); + + await waitFor(() => expect(toastCalls).toContainEqual(['Storage full', 'error'])); + }); + + it('FE-MOB-NTSH-022: removing a pending cover needs no request', async () => { + const update = vi.spyOn(tripsApi, 'update'); + render( {}} onSave={() => {}} />); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(screen.getByText('Add cover image')).toBeInTheDocument()); + expect(update).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NTSH-023: removing a stored cover clears it on the server', async () => { + const update = vi.spyOn(tripsApi, 'update').mockResolvedValue({ trip: { id: 42 } as Trip }); + const onCoverUpdate = vi.fn(); + render( {}} onSave={() => {}} onCoverUpdate={onCoverUpdate} />); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(update).toHaveBeenCalledWith(42, { cover_image: null })); + expect(onCoverUpdate).toHaveBeenCalledWith(42, null); + }); + + it('FE-MOB-NTSH-024: a failed cover removal toasts and keeps the image', async () => { + vi.spyOn(tripsApi, 'update').mockRejectedValue(new Error('nope')); + render( {}} onSave={() => {}} />); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(toastCalls).toContainEqual(['Failed to remove', 'error'])); + expect(document.querySelector('img[src="/uploads/covers/a.jpg"]')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-025: archiving from the edit sheet runs the action and closes', () => { + const onArchive = vi.fn(); + const onClose = vi.fn(); + render( {}} onArchive={onArchive} />); + + fireEvent.click(screen.getByText('Archive')); + + expect(onArchive).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-NTSH-026: an archived trip offers restore instead of archive', () => { + render( + {}} onSave={() => {}} onArchive={() => {}} />, + ); + + expect(screen.getByText('Restore')).toBeInTheDocument(); + expect(screen.queryByText('Archive')).not.toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-027: without trip_edit the text fields are read-only', () => { + usePermissionsStore.setState({ permissions: { trip_edit: 'trip_owner' } }); + render( {}} onSave={() => {}} />); + + const title = screen.getByPlaceholderText('e.g. Summer in Japan'); + expect(title).toHaveAttribute('readonly'); + fireEvent.change(title, { target: { value: 'hijacked' } }); + expect(title).toHaveValue('Japan 2026'); + expect(screen.getByLabelText('currency')).toBeDisabled(); + }); + + it('FE-MOB-NTSH-028: without trip_cover_upload the whole cover block is hidden', () => { + usePermissionsStore.setState({ permissions: { trip_cover_upload: 'trip_owner' } }); + render( {}} onSave={() => {}} />); + + expect(screen.queryByPlaceholderText('Search destination photos')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Search Unsplash' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-030: only the newest search may apply its result', async () => { + let resolveFirst: (v: { photos: CoverSearchPhoto[] }) => void = () => {}; + const search = vi.spyOn(tripsApi, 'searchCoverImages') + .mockImplementationOnce(() => new Promise(res => { resolveFirst = res; })) + .mockResolvedValueOnce({ photos: [{ id: 'b', url: 'u2', thumb: 't2', photographer: 'Second' }] }); + render( {}} onSave={() => {}} />); + const button = screen.getByRole('button', { name: 'Search Unsplash' }); + + const field = screen.getByPlaceholderText('Search destination photos'); + fireEvent.change(field, { target: { value: 'a' } }); + fireEvent.click(button); + // The button disables itself while a search runs; Enter still starts the next one. + fireEvent.keyDown(field, { key: 'Enter' }); + await screen.findByRole('button', { name: 'Use Unsplash photo by Second' }); + + // The stale first response must not replace the newer results. + await act(async () => { resolveFirst({ photos: [{ id: 'a', url: 'u1', thumb: 't1', photographer: 'First' }] }); }); + + expect(screen.queryByRole('button', { name: 'Use Unsplash photo by First' })).not.toBeInTheDocument(); + expect(search).toHaveBeenCalledTimes(2); + }); + + it('FE-MOB-NTSH-031: a stale search failure does not overwrite the newer results', async () => { + let rejectFirst: (e: unknown) => void = () => {}; + vi.spyOn(tripsApi, 'searchCoverImages') + .mockImplementationOnce(() => new Promise((_res, rej) => { rejectFirst = rej; })) + .mockResolvedValueOnce({ photos: [{ id: 'b', url: 'u2', thumb: 't2', photographer: 'Second' }] }); + render( {}} onSave={() => {}} />); + const button = screen.getByRole('button', { name: 'Search Unsplash' }); + + const field = screen.getByPlaceholderText('Search destination photos'); + fireEvent.change(field, { target: { value: 'a' } }); + fireEvent.click(button); + fireEvent.keyDown(field, { key: 'Enter' }); + await screen.findByRole('button', { name: 'Use Unsplash photo by Second' }); + + await act(async () => { rejectFirst(new Error('too late')); }); + + expect(screen.queryByText('too late')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use Unsplash photo by Second' })).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-032: a result without a full-size url cannot be picked', async () => { + vi.spyOn(tripsApi, 'searchCoverImages').mockResolvedValue({ + photos: [{ id: 'p1', url: '', thumb: 'https://img/thumb.jpg', photographer: 'Ada' }], + }); + const update = vi.spyOn(tripsApi, 'update'); + render( {}} onSave={() => {}} />); + + fireEvent.change(screen.getByPlaceholderText('Search destination photos'), { target: { value: 'iceland' } }); + fireEvent.click(screen.getByRole('button', { name: 'Search Unsplash' })); + fireEvent.click(await screen.findByRole('button', { name: 'Use Unsplash photo by Ada' })); + + await waitFor(() => expect(update).not.toHaveBeenCalled()); + expect(screen.getByText('Add cover image')).toBeInTheDocument(); + }); + + it('FE-MOB-NTSH-033: the cover buttons open the device file picker', async () => { + const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {}); + render( {}} onSave={() => {}} />); + + fireEvent.click(screen.getByText('Add cover image')); + expect(click).toHaveBeenCalledTimes(1); + + fireEvent.change(fileInput(), { target: { files: [png()] } }); + fireEvent.click(await screen.findByText('Change')); + expect(click).toHaveBeenCalledTimes(2); + }); + + it('FE-MOB-NTSH-029: the cancel actions close without saving', () => { + const onClose = vi.fn(); + const onSave = vi.fn(async () => undefined); + render(); + + // Both the header X and the footer button carry the cancel label. + const cancels = screen.getAllByRole('button', { name: 'Cancel' }); + expect(cancels).toHaveLength(2); + cancels.forEach(btn => fireEvent.click(btn)); + + expect(onClose).toHaveBeenCalledTimes(2); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/client/tests/unit/mobile/dashboard/MUserMenu.test.tsx b/client/tests/unit/mobile/dashboard/MUserMenu.test.tsx index c3ceec47b..720acbd44 100644 --- a/client/tests/unit/mobile/dashboard/MUserMenu.test.tsx +++ b/client/tests/unit/mobile/dashboard/MUserMenu.test.tsx @@ -51,4 +51,59 @@ describe('MUserMenu', () => { fireEvent.click(screen.getByRole('button', { name: /Color Mode/ })); expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto'); }); + + it('FE-MOB-MENU-004: auto cycles back to dark', () => { + const updateSetting = vi.fn(async () => {}); + useSettingsStore.setState({ updateSetting }); + useSettingsStore.setState(s => ({ settings: { ...s.settings, dark_mode: 'auto' } })); + + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: /Color Mode/ })); + + expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'dark'); + }); + + it('FE-MOB-MENU-005: a legacy boolean dark_mode still resolves to dark', () => { + const updateSetting = vi.fn(async () => {}); + useSettingsStore.setState({ updateSetting }); + useSettingsStore.setState(s => ({ settings: { ...s.settings, dark_mode: true } })); + + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: /Color Mode/ })); + + expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'light'); + }); + + it('FE-MOB-MENU-006: opening settings closes the menu first', () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MENU-007: signing out closes the menu and clears the session', async () => { + const onClose = vi.fn(); + const logout = vi.fn(async () => {}); + useAuthStore.setState({ logout }); + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Logout' })); + }); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(logout).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MENU-008: an uploaded avatar replaces the initial', () => { + useAuthStore.setState({ + isAuthenticated: true, + user: { id: 1, username: 'Maurice', email: 'm@trek.app', role: 'user', avatar_url: '/uploads/avatars/m.jpg' } as never, + }); + const { container } = render( {}} />); + + expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg'); + }); }); diff --git a/client/tests/unit/mobile/journey/MJourney.test.tsx b/client/tests/unit/mobile/journey/MJourney.test.tsx new file mode 100644 index 000000000..a87739427 --- /dev/null +++ b/client/tests/unit/mobile/journey/MJourney.test.tsx @@ -0,0 +1,152 @@ +// FE-MOB-JLIST-001 to FE-MOB-JLIST-012 +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '../../../helpers/render'; +import MJourney from '../../../../src/mobile/screens/journey/MJourney'; +import type { Journey } from '../../../../src/store/journeyStore'; + +const mocks = vi.hoisted(() => ({ journey: {} as Record })); + +vi.mock('../../../../src/pages/journey/useJourney', () => ({ + useJourney: () => mocks.journey, +})); + +type ListItem = Journey & { entry_count?: number; photo_count?: number; place_count?: number }; + +function buildJourney(over: Partial = {}): ListItem { + return { + id: 1, + user_id: 1, + title: 'Japan 2026', + subtitle: null, + cover_gradient: null, + cover_image: null, + status: 'active', + created_at: 0, + updated_at: 0, + ...over, + }; +} + +function buildHook(over: Record = {}): Record { + return { + navigate: vi.fn(), + journeys: [] as ListItem[], + loading: false, + showCreate: false, + setShowCreate: vi.fn(), + newTitle: '', + setNewTitle: vi.fn(), + availableTrips: [], + selectedTripIds: new Set(), + setSelectedTripIds: vi.fn(), + openCreateModal: vi.fn(), + handleCreate: vi.fn(), + activeJourney: null, + ...over, + }; +} + +function setup(over: Record = {}) { + mocks.journey = buildHook(over); + render(); + return mocks.journey; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MJourney', () => { + it('FE-MOB-JLIST-001: shows a spinner while the first load is running', () => { + setup({ loading: true, journeys: [] }); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByText('Other journeys')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-002: shows the mascot empty state when there are no journeys', () => { + setup(); + expect(screen.getByText('Create a new Journey')).toBeInTheDocument(); + expect(document.querySelector('.animate-spin')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-003: renders the active journey as hero with its counters', () => { + const hero = buildJourney({ id: 4, title: 'Norway', entry_count: 12, photo_count: 40, place_count: 7 }); + setup({ journeys: [hero], activeJourney: hero }); + + expect(screen.getByText('Latest journey')).toBeInTheDocument(); + expect(screen.getByText('Norway')).toBeInTheDocument(); + expect(screen.getByText('12 entries')).toBeInTheDocument(); + expect(screen.getByText('40 photos')).toBeInTheDocument(); + expect(screen.getByText('7 places')).toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-004: falls back to the first list entry when no journey is active', () => { + const first = buildJourney({ id: 9, title: 'Fallback hero' }); + setup({ journeys: [first], activeJourney: null }); + expect(screen.getByText('Fallback hero')).toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-005: renders the hero cover image when one is set', () => { + const hero = buildJourney({ id: 4, title: 'Norway', cover_image: 'covers/n.jpg' }); + setup({ journeys: [hero], activeJourney: hero }); + expect(document.querySelector('img')).toHaveAttribute('src', '/uploads/covers/n.jpg'); + }); + + it('FE-MOB-JLIST-006: opening the hero navigates to its detail route', () => { + const hero = buildJourney({ id: 4, title: 'Norway' }); + const hook = setup({ journeys: [hero], activeJourney: hero }); + fireEvent.click(screen.getByText('Norway')); + expect(hook.navigate).toHaveBeenCalledWith('/journey/4'); + }); + + it('FE-MOB-JLIST-007: lists the remaining journeys in the grid with their counters', () => { + const hero = buildJourney({ id: 4, title: 'Norway' }); + const other = buildJourney({ id: 5, title: 'Iceland', entry_count: 3, photo_count: 0, place_count: 0 }); + setup({ journeys: [hero, other], activeJourney: hero }); + + expect(screen.getByText('Other journeys')).toBeInTheDocument(); + expect(screen.getByText('Iceland')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-008: hides the counter row on a grid card without any stats', () => { + const hero = buildJourney({ id: 4, title: 'Norway' }); + const empty = buildJourney({ id: 5, title: 'Iceland' }); + setup({ journeys: [hero, empty], activeJourney: hero }); + + expect(screen.getByText('Iceland')).toBeInTheDocument(); + expect(screen.queryByText('0')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JLIST-009: opening a grid card navigates to that journey', () => { + const hero = buildJourney({ id: 4, title: 'Norway' }); + const other = buildJourney({ id: 5, title: 'Iceland' }); + const hook = setup({ journeys: [hero, other], activeJourney: hero }); + fireEvent.click(screen.getByText('Iceland')); + expect(hook.navigate).toHaveBeenCalledWith('/journey/5'); + }); + + it('FE-MOB-JLIST-010: the header pill opens the create modal', () => { + const hook = setup(); + fireEvent.click(screen.getByRole('button', { name: /Create Journey/ })); + expect(hook.openCreateModal).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-JLIST-011: toggling a trip in the create sheet adds and removes the id', () => { + const trips = [{ id: 21, title: 'Tokyo', start_date: '2026-05-01', end_date: '2026-05-04', place_count: 3 }]; + const hook = setup({ showCreate: true, newTitle: 'Japan', availableTrips: trips }); + + fireEvent.click(screen.getByText('Tokyo')); + const setSelected = hook.setSelectedTripIds as ReturnType; + const updater = setSelected.mock.calls[0][0] as (prev: Set) => Set; + expect([...updater(new Set())]).toEqual([21]); + expect([...updater(new Set([21]))]).toEqual([]); + }); + + it('FE-MOB-JLIST-012: closing the create sheet clears the flag on the hook', () => { + const hook = setup({ showCreate: true, newTitle: 'Japan' }); + const cancels = screen.getAllByRole('button', { name: 'Cancel' }); + fireEvent.click(cancels[cancels.length - 1]); + expect(hook.setShowCreate).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx b/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx new file mode 100644 index 000000000..c0f196f09 --- /dev/null +++ b/client/tests/unit/mobile/journey/MJourneyDetail.test.tsx @@ -0,0 +1,561 @@ +// FE-MOB-JDET-001 to FE-MOB-JDET-031 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../../helpers/render'; +import MJourneyDetail from '../../../../src/mobile/screens/journey/MJourneyDetail'; +import { addonsApi, journeyApi } from '../../../../src/api/client'; +import { useJourneyStore } from '../../../../src/store/journeyStore'; +import { useAuthStore } from '../../../../src/store/authStore'; +import type { GalleryPhoto, JourneyDetail, JourneyEntry } from '../../../../src/store/journeyStore'; + +const mocks = vi.hoisted(() => ({ + detail: {} as Record, + focusMarker: vi.fn(), + highlightMarker: vi.fn(), + captured: {} as Record>, +})); + +vi.mock('../../../../src/pages/journeyDetail/useJourneyDetail', () => ({ + useJourneyDetail: () => mocks.detail, +})); + +vi.mock('../../../../src/components/Journey/JourneyMapAuto', async () => { + const React = await import('react'); + const Comp = React.forwardRef((props: Record, ref: React.Ref) => { + React.useImperativeHandle(ref, () => ({ + focusMarker: mocks.focusMarker, + highlightMarker: mocks.highlightMarker, + invalidateSize: vi.fn(), + })); + const entries = props.entries as { id: string }[]; + const onMarkerClick = props.onMarkerClick as ((id: string) => void) | undefined; + return ( +
+ {entries.map(e => ( + + ))} +
+ ); + }); + Comp.displayName = 'MockJourneyMapAuto'; + return { __esModule: true, default: Comp }; +}); + +vi.mock('../../../../src/components/Journey/PhotoLightbox', () => ({ + default: (props: Record) => { + mocks.captured.lightbox = props; + return
; + }, +})); + +vi.mock('../../../../src/components/Journey/ContributorInviteDialog', () => ({ + default: (props: Record) => { + mocks.captured.invite = props; + return
; + }, +})); + +vi.mock('../../../../src/components/Journey/JourneyDetailPageProviderPicker', () => ({ + ProviderPicker: (props: Record) => { + mocks.captured.picker = props; + return
; + }, +})); + +vi.mock('../../../../src/mobile/screens/journey/MJourneyEntrySheet', () => ({ + default: (props: Record) => { + mocks.captured.entrySheet = props; + return
; + }, +})); + +vi.mock('../../../../src/mobile/screens/journey/MJourneySettingsSheet', () => ({ + default: (props: Record) => { + mocks.captured.settings = props; + return
; + }, +})); + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function buildEntry(over: Partial = {}): JourneyEntry { + return { + id: 1, + journey_id: 12, + author_id: 1, + type: 'entry', + title: 'Arrival', + story: null, + entry_date: '2026-05-01', + entry_time: '10:00', + location_name: 'Tokyo', + location_lat: 35.6, + location_lng: 139.7, + mood: null, + weather: null, + tags: [], + pros_cons: null, + visibility: 'private', + sort_order: 0, + photos: [], + created_at: 0, + updated_at: 0, + ...over, + }; +} + +function buildGalleryPhoto(over: Partial = {}): GalleryPhoto { + return { + id: 501, + journey_id: 12, + photo_id: 900, + caption: 'Shrine', + shared: 1, + sort_order: 0, + created_at: 0, + provider: 'local', + asset_id: null, + owner_id: 1, + file_path: null, + thumbnail_path: 'thumbs/900.jpg', + media_type: 'image', + ...over, + }; +} + +function buildDetail(over: Partial = {}): JourneyDetail { + return { + id: 12, + user_id: 1, + title: 'Japan 2026', + subtitle: null, + cover_gradient: null, + cover_image: null, + status: 'active', + created_at: 0, + updated_at: 0, + entries: [buildEntry(), buildEntry({ id: 2, title: 'Kyoto', entry_date: '2026-05-02' })], + gallery: [], + trips: [], + contributors: [{ journey_id: 12, user_id: 5, role: 'owner', added_at: 0, username: 'maurice', avatar: null }], + stats: { entries: 2, photos: 0, places: 0 }, + ...over, + }; +} + +const toast = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }; + +function buildHook(over: Record = {}): Record { + const current = (over.current as JourneyDetail | null | undefined) ?? buildDetail(); + return { + id: '12', + navigate: vi.fn(), + toast, + t: (key: string) => key, + current, + loading: false, + canEditEntries: true, + canEditJourney: true, + view: 'timeline', + setView: vi.fn(), + editingEntry: null, + setEditingEntry: vi.fn(), + lightbox: null, + setLightbox: vi.fn(), + deleteTarget: null, + setDeleteTarget: vi.fn(), + showInvite: false, + setShowInvite: vi.fn(), + showSettings: false, + setShowSettings: vi.fn(), + hideSkeletons: false, + sidebarMapItems: (current?.entries ?? []).map(e => ({ id: String(e.id), lat: 1, lng: 2, title: e.title ?? '' })), + loadJourney: vi.fn(), + updateEntry: vi.fn(async () => {}), + deleteEntry: vi.fn(async () => {}), + uploadPhotos: vi.fn(async () => ({ succeeded: [], failed: [] })), + ...over, + }; +} + +function setup(over: Record = {}) { + mocks.detail = buildHook(over); + const view = render(); + return { ...view, hook: mocks.detail }; +} + +const journeyStoreInitial = useJourneyStore.getState(); +let uploadGalleryPhotos: ReturnType; +let createEntry: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.captured = {}; + uploadGalleryPhotos = vi.fn(async () => ({ succeeded: [], failed: [] })); + createEntry = vi.fn(async () => buildEntry({ id: 77 })); + useJourneyStore.setState({ ...journeyStoreInitial, uploadGalleryPhotos, createEntry } as never, true); + useAuthStore.setState({ user: { id: 5, username: 'maurice' } } as never); + vi.spyOn(addonsApi, 'enabled').mockResolvedValue({ addons: [] }); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + useJourneyStore.setState(journeyStoreInitial, true); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('MJourneyDetail', () => { + it('FE-MOB-JDET-001: shows a spinner while the journey is loading', () => { + setup({ loading: true, current: null }); + expect(document.querySelector('.animate-spin')).toBeInTheDocument(); + expect(screen.queryByTestId('journey-map')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JDET-002: renders one timeline card per entry with the map behind it', () => { + setup(); + expect(screen.getByTestId('journey-map')).toBeInTheDocument(); + expect(screen.getByText('Arrival')).toBeInTheDocument(); + expect(screen.getByText('Kyoto')).toBeInTheDocument(); + expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1'); + }); + + it('FE-MOB-JDET-003: hideSkeletons drops the trip-derived skeleton entries', () => { + const current = buildDetail({ entries: [buildEntry(), buildEntry({ id: 3, type: 'skeleton', title: 'Suggested' })] }); + setup({ current, hideSkeletons: true }); + expect(screen.getByText('Arrival')).toBeInTheDocument(); + expect(screen.queryByText('Suggested')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JDET-004: tapping the centred card opens the entry sheet', () => { + const { hook } = setup(); + fireEvent.click(screen.getByText('Arrival')); + expect(hook.setEditingEntry).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + }); + + it('FE-MOB-JDET-005: tapping an off-centre card focuses it on the map instead of opening it', () => { + const { hook } = setup(); + fireEvent.click(screen.getByText('Kyoto')); + expect(hook.setEditingEntry).not.toHaveBeenCalled(); + expect(mocks.focusMarker).toHaveBeenCalledWith('2'); + expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2'); + }); + + it('FE-MOB-JDET-006: clicking a map marker centres the matching card', () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: 'marker-2' })); + expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2'); + }); + + it('FE-MOB-JDET-007: a marker without a matching entry leaves the selection alone', () => { + const current = buildDetail(); + setup({ + current, + sidebarMapItems: [{ id: '99', lat: 1, lng: 2, title: 'orphan' }], + }); + fireEvent.click(screen.getByRole('button', { name: 'marker-99' })); + expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1'); + }); + + it('FE-MOB-JDET-008: an entry with no marker clears the highlight when it is centred', () => { + const current = buildDetail(); + setup({ current, sidebarMapItems: [{ id: '1', lat: 1, lng: 2, title: 'Arrival' }] }); + fireEvent.click(screen.getByText('Kyoto')); + expect(mocks.highlightMarker).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-JDET-009: the segment control switches between the journey and gallery tabs', () => { + const { hook } = setup(); + fireEvent.click(screen.getByRole('button', { name: /journey.share.gallery/ })); + expect(hook.setView).toHaveBeenCalledWith('gallery'); + fireEvent.click(screen.getByRole('button', { name: /journey.detail.journeyTab/ })); + expect(hook.setView).toHaveBeenCalledWith('timeline'); + }); + + it('FE-MOB-JDET-010: back navigates to the journey list', () => { + const { hook } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'journey.detail.backToJourney' })); + expect(hook.navigate).toHaveBeenCalledWith('/journey'); + }); + + it('FE-MOB-JDET-011: the gallery tab shows the empty state when there are no photos', () => { + setup({ view: 'gallery' }); + expect(screen.getByText('journey.detail.noPhotos')).toBeInTheDocument(); + }); + + it('FE-MOB-JDET-012: gallery photos open the lightbox at the tapped index', () => { + const gallery = [buildGalleryPhoto(), buildGalleryPhoto({ id: 502, photo_id: 901, caption: null })]; + const { hook } = setup({ view: 'gallery', current: buildDetail({ gallery }) }); + + const tiles = screen.getAllByRole('button').filter(b => b.querySelector('img')); + fireEvent.click(tiles[1]); + expect(hook.setLightbox).toHaveBeenCalledWith({ + index: 1, + photos: [ + expect.objectContaining({ id: 501, src: '/api/photos/900/original', caption: 'Shrine' }), + expect.objectContaining({ id: 502, src: '/api/photos/901/original', caption: null }), + ], + }); + }); + + it('FE-MOB-JDET-013: a video without a poster renders a placeholder tile plus the play badge', () => { + const gallery = [buildGalleryPhoto({ media_type: 'video', thumbnail_path: null })]; + setup({ view: 'gallery', current: buildDetail({ gallery }) }); + expect(document.querySelector('img')).not.toBeInTheDocument(); + expect(document.querySelector('.absolute.inset-0.flex.items-center')).toBeInTheDocument(); + }); + + it('FE-MOB-JDET-014: uploading from the device sends the picked files and reloads', async () => { + const { hook, container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'], 'p.jpg', { type: 'image/jpeg' }); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(uploadGalleryPhotos).toHaveBeenCalledWith(12, [file])); + expect(toast.success).toHaveBeenCalledWith('journey.photosUploaded'); + expect(hook.loadJourney).toHaveBeenCalledWith(12); + }); + + it('FE-MOB-JDET-015: a partially failed upload reports the failure count', async () => { + const failed = new File(['x'], 'bad.jpg', { type: 'image/jpeg' }); + uploadGalleryPhotos.mockResolvedValueOnce({ succeeded: [], failed: [failed] }); + const { container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [failed] } }); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('journey.editor.uploadPartialFailed')); + }); + + it('FE-MOB-JDET-016: a rejected upload surfaces the API error', async () => { + uploadGalleryPhotos.mockRejectedValueOnce(new Error('disk full')); + const { container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'p.jpg', { type: 'image/jpeg' })] } }); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith('disk full')); + }); + + it('FE-MOB-JDET-017: an empty file selection does not upload', () => { + const { container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [] } }); + expect(uploadGalleryPhotos).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JDET-018: a connected photo provider turns the upload button into a source chooser', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [ + { id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }, + { id: 'legacy', name: 'Legacy', type: 'other', enabled: true }, + ], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 }))); + setup({ view: 'gallery' }); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + expect(await screen.findByText('mobileJourney.uploadFromDevice')).toBeInTheDocument(); + fireEvent.click(screen.getByText('mobileJourney.browseProvider')); + expect(await screen.findByTestId('provider-picker')).toBeInTheDocument(); + }); + + it('FE-MOB-JDET-019: adding provider photos reports the count and reloads', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 }))); + const toGallery = vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockResolvedValue({ added: 3 }); + const toEntry = vi.spyOn(journeyApi, 'addProviderPhotos').mockResolvedValue({ added: 1 }); + const { hook } = setup({ view: 'gallery' }); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + fireEvent.click(screen.getByText('mobileJourney.browseProvider')); + await screen.findByTestId('provider-picker'); + + const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise; + await onAdd([{ assetIds: ['a1'] }], undefined); + expect(toGallery).toHaveBeenCalledWith(12, 'immich', ['a1'], undefined, undefined); + expect(toast.success).toHaveBeenCalledWith('journey.photosAdded'); + expect(hook.loadJourney).toHaveBeenCalledWith(12); + + await onAdd([{ assetIds: ['a2'] }], 1); + expect(toEntry).toHaveBeenCalledWith(1, 'immich', ['a2'], undefined, undefined, undefined); + }); + + it('FE-MOB-JDET-020: a failed provider add reports the error instead of a success', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 }))); + vi.spyOn(journeyApi, 'addProviderPhotosToGallery').mockRejectedValue(new Error('boom')); + setup({ view: 'gallery' }); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + fireEvent.click(screen.getByText('mobileJourney.browseProvider')); + await screen.findByTestId('provider-picker'); + + const onAdd = mocks.captured.picker.onAdd as (g: { assetIds: string[] }[], entryId?: number) => Promise; + await onAdd([{ assetIds: ['a1'] }], undefined); + expect(toast.error).toHaveBeenCalledWith('common.error'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JDET-021: the entry sheet creates a new entry in quick-capture mode', async () => { + const { hook } = setup({ editingEntry: buildEntry({ id: 0, title: null }) }); + const sheet = mocks.captured.entrySheet; + expect(screen.getByTestId('entry-sheet')).toHaveAttribute('data-quick', 'true'); + expect(sheet.onDelete).toBeUndefined(); + + const onSave = sheet.onSave as (d: Record) => Promise; + await expect(onSave({ title: 'Fresh' })).resolves.toBe(77); + expect(createEntry).toHaveBeenCalledWith(12, { title: 'Fresh' }); + + (sheet.onDone as () => void)(); + expect(hook.setEditingEntry).toHaveBeenCalledWith(null); + expect(hook.loadJourney).toHaveBeenCalledWith(12); + }); + + it('FE-MOB-JDET-022: editing an existing entry updates it and can hand off to the delete confirm', async () => { + const entry = buildEntry({ id: 4, title: 'Old' }); + const { hook } = setup({ editingEntry: entry }); + const sheet = mocks.captured.entrySheet; + + const onSave = sheet.onSave as (d: Record) => Promise; + await expect(onSave({ title: 'New' })).resolves.toBe(4); + expect(hook.updateEntry).toHaveBeenCalledWith(4, { title: 'New' }); + + (sheet.onDelete as () => void)(); + expect(hook.setEditingEntry).toHaveBeenCalledWith(null); + expect(hook.setDeleteTarget).toHaveBeenCalledWith(entry); + }); + + it('FE-MOB-JDET-023: a read-only viewer gets the sheet without a delete action', () => { + setup({ canEditEntries: false, editingEntry: buildEntry({ id: 4 }) }); + expect(screen.getByTestId('entry-sheet')).toHaveAttribute('data-readonly', 'true'); + expect(mocks.captured.entrySheet.onDelete).toBeUndefined(); + }); + + it('FE-MOB-JDET-024: confirming the delete removes the entry and reloads', async () => { + const target = buildEntry({ id: 4, title: 'Old' }); + const { hook } = setup({ deleteTarget: target }); + fireEvent.click(screen.getByRole('button', { name: 'common.delete' })); + + await waitFor(() => expect(hook.deleteEntry).toHaveBeenCalledWith(4)); + expect(hook.setDeleteTarget).toHaveBeenCalledWith(null); + expect(hook.loadJourney).toHaveBeenCalledWith(12); + }); + + it('FE-MOB-JDET-025: settings and invite dialogs wire their callbacks back into the hook', () => { + const { hook } = setup({ showSettings: true, showInvite: true }); + + expect(screen.getByTestId('settings-sheet')).toBeInTheDocument(); + (mocks.captured.settings.onSaved as () => void)(); + expect(hook.setShowSettings).toHaveBeenCalledWith(false); + (mocks.captured.settings.onOpenInvite as () => void)(); + expect(hook.setShowInvite).toHaveBeenCalledWith(true); + (mocks.captured.settings.onRefresh as () => void)(); + + expect(screen.getByTestId('invite-dialog')).toBeInTheDocument(); + expect(mocks.captured.invite.existingUserIds).toEqual([5]); + (mocks.captured.invite.onInvited as () => void)(); + expect(hook.setShowInvite).toHaveBeenCalledWith(false); + expect(hook.loadJourney).toHaveBeenCalledWith(12); + }); + + it('FE-MOB-JDET-026: the settings button is hidden for a non-owner and shown for the owner', () => { + const { hook, unmount } = setup(); + fireEvent.click(screen.getByRole('button', { name: 'journey.settings.title' })); + expect(hook.setShowSettings).toHaveBeenCalledWith(true); + unmount(); + + setup({ canEditJourney: false }); + expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-JDET-027: the lightbox renders the mapped photos and closes back into the hook', () => { + const { hook } = setup({ + lightbox: { photos: [{ id: 501, src: '/api/photos/900/original', caption: 'Shrine', mediaType: 'image' }], index: 0 }, + }); + expect(screen.getByTestId('lightbox')).toBeInTheDocument(); + expect(mocks.captured.lightbox.photos).toEqual([ + expect.objectContaining({ id: '501', src: '/api/photos/900/original', caption: 'Shrine' }), + ]); + (mocks.captured.lightbox.onClose as () => void)(); + expect(hook.setLightbox).toHaveBeenCalledWith(null); + }); + + it('FE-MOB-JDET-028: a settled carousel scroll re-centres the map on the nearest card', async () => { + setup(); + // Let the photo-provider probe land first: its state update re-runs the + // carousel effect and would drop the pending scroll-settle timer. + await waitFor(() => expect(addonsApi.enabled).toHaveBeenCalled()); + + fireEvent.click(screen.getByText('Kyoto')); + expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '2'); + + const carousel = document.querySelector('.overflow-x-auto') as HTMLElement; + // two bursts in a row: only the last one may settle + fireEvent.scroll(carousel); + fireEvent.scroll(carousel); + await waitFor(() => expect(screen.getByTestId('journey-map')).toHaveAttribute('data-active', '1')); + }); + + it('FE-MOB-JDET-029: the device row of the upload chooser opens the file picker', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 }))); + const { container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + const click = vi.spyOn(input, 'click').mockImplementation(() => {}); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + fireEvent.click(await screen.findByText('mobileJourney.uploadFromDevice')); + expect(click).toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument()); + }); + + it('FE-MOB-JDET-030: a provider that reports itself as disconnected stays hidden', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: false }), { status: 200 }))); + const { container } = setup({ view: 'gallery' }); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + const click = vi.spyOn(input, 'click').mockImplementation(() => {}); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + expect(click).toHaveBeenCalled(); + expect(screen.queryByText('mobileJourney.uploadFromDevice')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JDET-031: closing the entry sheet, settings, invite and picker resets the hook state', async () => { + vi.mocked(addonsApi.enabled).mockResolvedValue({ + addons: [{ id: 'immich', name: 'Immich', type: 'photo_provider', enabled: true }], + }); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ connected: true }), { status: 200 }))); + const gallery = [buildGalleryPhoto({ id: 601, asset_id: 'asset-1' }), buildGalleryPhoto({ id: 602, asset_id: null })]; + const { hook } = setup({ + view: 'gallery', + current: buildDetail({ gallery }), + editingEntry: buildEntry({ id: 4 }), + showSettings: true, + showInvite: true, + }); + + (mocks.captured.entrySheet.onClose as () => void)(); + expect(hook.setEditingEntry).toHaveBeenCalledWith(null); + (mocks.captured.settings.onClose as () => void)(); + expect(hook.setShowSettings).toHaveBeenCalledWith(false); + (mocks.captured.invite.onClose as () => void)(); + expect(hook.setShowInvite).toHaveBeenCalledWith(false); + + fireEvent.click(await screen.findByRole('button', { name: 'common.upload' })); + fireEvent.click(screen.getByText('mobileJourney.browseProvider')); + await screen.findByTestId('provider-picker'); + expect([...(mocks.captured.picker.existingAssetIds as Set)]).toEqual(['asset-1']); + + (mocks.captured.picker.onClose as () => void)(); + await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument()); + }); +}); diff --git a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx index f88ad8e7e..cf9f155d9 100644 --- a/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx +++ b/client/tests/unit/mobile/journey/MJourneyEntrySheet.test.tsx @@ -1,8 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import userEvent from '@testing-library/user-event'; import { mapsApi, weatherApi } from '../../../../src/api/client'; import MJourneyEntrySheet from '../../../../src/mobile/screens/journey/MJourneyEntrySheet'; -import type { JourneyEntry } from '../../../../src/store/journeyStore'; -import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import type { GalleryPhoto, JourneyEntry, JourneyPhoto } from '../../../../src/store/journeyStore'; +import type { ResilientResult, UploadProgress } from '../../../../src/utils/uploadQueue'; +import { server } from '../../../helpers/msw/server'; +import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render'; const entry: JourneyEntry = { id: 0, @@ -131,4 +135,815 @@ describe('MJourneyEntrySheet quick capture', () => { ); expect(props.onDone).toHaveBeenCalled(); }); + + it('FE-MOB-JENTRY-001: opens the camera and the gallery picker from the capture buttons', async () => { + vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: null, address: null }); + vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({ + temp: 0, main: '', description: '', type: '', error: 'unavailable', + }); + const user = userEvent.setup(); + setup(); + + const camera = document.querySelector('input[type="file"][capture="environment"]') as HTMLInputElement; + const gallery = document.querySelector('input[type="file"][multiple]') as HTMLInputElement; + const cameraClick = vi.spyOn(camera, 'click'); + const galleryClick = vi.spyOn(gallery, 'click'); + + await user.click(screen.getByRole('button', { name: 'Photo' })); + await user.click(screen.getByRole('button', { name: 'Gallery' })); + + expect(cameraClick).toHaveBeenCalledTimes(1); + expect(galleryClick).toHaveBeenCalledTimes(1); + await waitFor(() => expect(mapsApi.reverse).toHaveBeenCalled()); + }); + + it('FE-MOB-JENTRY-002: closes a quick capture without a discard prompt', async () => { + vi.spyOn(mapsApi, 'reverse').mockResolvedValue({ name: 'Singapore', address: 'Singapore' }); + vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({ + temp: 0, main: '', description: '', type: '', error: 'unavailable', + }); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); + const { props } = setup(); + + await waitFor(() => expect(screen.getByDisplayValue('Singapore')).toBeInTheDocument()); + // Header X and footer button share the "Cancel" label; the footer one is second. + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); +}); + +// FE-MOB-JENTRY-003 to FE-MOB-JENTRY-033 — the full editor, read-only mode and the +// quick-capture races the tests above don't reach. + +type ToastKind = 'success' | 'error' | 'warning' | 'info'; + +const toastSpy = vi.fn((_message: string, _type?: ToastKind, _duration?: number) => 0); + +function buildEntry(overrides: Partial = {}): JourneyEntry { + return { + id: 0, + journey_id: 7, + author_id: 3, + type: 'entry', + entry_date: '2026-03-15', + visibility: 'private', + sort_order: 0, + photos: [], + created_at: 0, + updated_at: 0, + ...overrides, + }; +} + +function buildPhoto(id: number): JourneyPhoto { + return { id, entry_id: 5, photo_id: id, caption: null, sort_order: 0, shared: 1, created_at: 0 }; +} + +function buildGalleryPhoto(id: number): GalleryPhoto { + return { id, journey_id: 7, photo_id: id, caption: null, shared: 1, sort_order: 0, created_at: 0 }; +} + +type UploadFn = ( + entryId: number, + files: File[], + cbs?: { onProgress?: (p: UploadProgress) => void }, +) => Promise>; + +interface MountOptions { + galleryPhotos?: GalleryPhoto[]; + quickCapture?: boolean; + readOnly?: boolean; + withDelete?: boolean; + upload?: UploadFn; +} + +const defaultUpload: UploadFn = async (_entryId, files, cbs) => { + cbs?.onProgress?.({ done: files.length, total: files.length, failed: 0, percent: 100 }); + return { succeeded: [], failed: [] }; +}; + +function mountSheet(sheetEntry: JourneyEntry, opts: MountOptions = {}) { + const onClose = vi.fn(); + const onDone = vi.fn(); + const onDelete = vi.fn(); + const onSave = vi.fn(async (_data: Record) => 42); + const onUploadPhotos = vi.fn(opts.upload ?? defaultUpload); + const view = render( + + ); + return { ...view, onClose, onDone, onDelete, onSave, onUploadPhotos }; +} + +const multiFileInput = () => document.querySelector('input[type="file"][multiple]') as HTMLInputElement; +const dateField = () => document.querySelector('input[type="date"]') as HTMLInputElement; +const timeField = () => document.querySelector('input[type="time"]') as HTMLInputElement; +const jpeg = (name = 'a.jpg') => new File(['x'], name, { type: 'image/jpeg' }); + +const originalCreateObjectURL = URL.createObjectURL; + +describe('MJourneyEntrySheet full editor', () => { + beforeEach(() => { + toastSpy.mockClear(); + window.__addToast = toastSpy; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, writable: true, value: vi.fn(() => 'blob:preview'), + }); + }); + + afterEach(() => { + delete window.__addToast; + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, writable: true, value: originalCreateObjectURL, + }); + }); + + it('FE-MOB-JENTRY-003: shows every editor section for a brand new entry', () => { + mountSheet(buildEntry({ entry_date: '' })); + + expect(screen.getByRole('dialog', { name: 'New Entry' })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Give this moment a name...')).toHaveValue(''); + expect(screen.getByPlaceholderText('Write your story...')).toHaveValue(''); + expect(screen.getByText('Pros & Cons')).toBeInTheDocument(); + expect(screen.getByText('Date')).toBeInTheDocument(); + expect(screen.getByText('Time')).toBeInTheDocument(); + expect(screen.getByText('Mood')).toBeInTheDocument(); + expect(screen.getByText('Weather')).toBeInTheDocument(); + expect(screen.getByText('Tags')).toBeInTheDocument(); + // An empty entry_date falls back to today, an empty gallery locks the picker. + expect(dateField().value).toBe(new Date().toISOString().split('T')[0]); + expect(timeField()).toHaveValue(''); + expect(screen.getByRole('button', { name: 'From Gallery' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Upload photos' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-004: seeds all fields from an existing entry', () => { + mountSheet(buildEntry({ + id: 5, + title: 'Roman holiday', + story: 'Gelato everywhere', + entry_time: '14:30:00', + location_name: 'Rome', + location_lat: 41.9, + location_lng: 12.5, + mood: 'good', + weather: 'sunny', + tags: ['food', 'city'], + pros_cons: { pros: ['Gelato'], cons: ['Queues'] }, + })); + + expect(screen.getByRole('dialog', { name: 'Edit Entry' })).toBeInTheDocument(); + expect(screen.getByDisplayValue('Roman holiday')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Gelato everywhere')).toBeInTheDocument(); + expect(timeField()).toHaveValue('14:30'); + expect(screen.getByDisplayValue('Rome')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Gelato')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Queues')).toBeInTheDocument(); + expect(screen.getByText('food')).toBeInTheDocument(); + expect(screen.getByText('city')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Good' }).className).not.toContain('text-m-muted'); + expect(screen.getByRole('button', { name: 'Sunny' }).className).toContain('bg-m-act'); + expect(screen.getByRole('button', { name: 'Rainy' }).className).toContain('text-m-muted'); + }); + + it('FE-MOB-JENTRY-005: saves title, story, date, time, pros, cons, tags, mood and weather', async () => { + const user = userEvent.setup(); + const { onSave, onDone } = mountSheet(buildEntry()); + + await user.type(screen.getByPlaceholderText('Give this moment a name...'), 'Rome'); + await user.type(screen.getByPlaceholderText('Write your story...'), 'Great day'); + fireEvent.change(dateField(), { target: { value: '2026-03-18' } }); + fireEvent.change(timeField(), { target: { value: '18:45' } }); + + const [addPro, addCon] = screen.getAllByRole('button', { name: /Add another/ }); + await user.click(addPro); + await user.click(addPro); + await user.click(addCon); + await user.type(screen.getAllByPlaceholderText('Something great...')[0], 'Gelato'); + await user.type(screen.getByPlaceholderText('Not so great...'), 'Crowds'); + + const tagInput = screen.getByPlaceholderText('Add tag'); + await user.type(tagInput, 'food{Enter}'); + await user.type(tagInput, 'city,'); + await user.type(tagInput, 'food{Enter}'); + + await user.click(screen.getByRole('button', { name: 'Amazing' })); + await user.click(screen.getByRole('button', { name: 'Rainy' })); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + // The second (still empty) pro row is dropped and the duplicate tag never lands. + expect(onSave).toHaveBeenCalledWith({ + title: 'Rome', + story: 'Great day', + entry_date: '2026-03-18', + entry_time: '18:45', + location_name: null, + location_lat: null, + location_lng: null, + mood: 'amazing', + weather: 'rainy', + tags: ['food', 'city'], + pros_cons: { pros: ['Gelato'], cons: ['Crowds'] }, + type: undefined, + }); + }); + + it('FE-MOB-JENTRY-006: removes a pro and a con row again', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ + id: 5, + pros_cons: { pros: ['Gelato', 'Sun'], cons: ['Queues', 'Heat'] }, + })); + + const pro = screen.getByDisplayValue('Sun').parentElement as HTMLElement; + await user.click(pro.querySelector('button') as HTMLElement); + const con = screen.getByDisplayValue('Heat').parentElement as HTMLElement; + await user.click(con.querySelector('button') as HTMLElement); + + expect(screen.queryByDisplayValue('Sun')).not.toBeInTheDocument(); + expect(screen.queryByDisplayValue('Heat')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ pros_cons: { pros: ['Gelato'], cons: ['Queues'] } }); + }); + + it('FE-MOB-JENTRY-007: edits an existing pro and con inline', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ + id: 5, + pros_cons: { pros: ['Gelato'], cons: ['Queues'] }, + })); + + await user.type(screen.getByDisplayValue('Gelato'), ' bars'); + await user.type(screen.getByDisplayValue('Queues'), ' everywhere'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ + pros_cons: { pros: ['Gelato bars'], cons: ['Queues everywhere'] }, + }); + }); + + it('FE-MOB-JENTRY-008: removes a tag chip', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5, tags: ['food', 'city'] })); + + const chip = screen.getByText('city'); + await user.click(chip.querySelector('button') as HTMLElement); + expect(screen.queryByText('city')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ tags: ['food'] }); + }); + + it('FE-MOB-JENTRY-009: promotes a skeleton to a real entry once it has a story', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton' })); + + await user.type(screen.getByPlaceholderText('Write your story...'), 'Gondolas'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ type: 'entry' }); + }); + + it('FE-MOB-JENTRY-010: leaves an untouched skeleton a skeleton', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 9, type: 'skeleton', title: 'Venice' })); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ type: undefined }); + }); + + it('FE-MOB-JENTRY-011: queues picked files, reports progress and uploads them on save', async () => { + const user = userEvent.setup(); + let release: (r: ResilientResult) => void = () => {}; + const upload: UploadFn = (_entryId, files, cbs) => + new Promise>(resolve => { + cbs?.onProgress?.({ done: 1, total: files.length, failed: 0, percent: 50 }); + release = resolve; + }); + const { onUploadPhotos, onDone } = mountSheet(buildEntry(), { upload }); + + const file = jpeg(); + await user.click(screen.getByRole('button', { name: 'Upload photos' })); + fireEvent.change(multiFileInput(), { target: { files: [file] } }); + await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument()); + + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(await screen.findByText('Uploading 1/1…')).toBeInTheDocument(); + + release({ succeeded: [], failed: [] }); + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(onUploadPhotos).toHaveBeenCalledWith(42, [file], expect.anything()); + }); + + it('FE-MOB-JENTRY-012: keeps files that failed to upload and warns about them', async () => { + const user = userEvent.setup(); + const file = jpeg(); + const { onUploadPhotos } = mountSheet(buildEntry()); + onUploadPhotos.mockResolvedValueOnce({ succeeded: [], failed: [file] }); + + fireEvent.change(multiFileInput(), { target: { files: [file] } }); + await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => + expect(toastSpy).toHaveBeenCalledWith('1 of 1 photos failed — save again to retry', 'error', undefined) + ); + expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-013: reports a rejected upload but still finishes the save', async () => { + const user = userEvent.setup(); + const { onUploadPhotos, onDone } = mountSheet(buildEntry()); + onUploadPhotos.mockRejectedValueOnce(new Error('disk full')); + + fireEvent.change(multiFileInput(), { target: { files: [jpeg()] } }); + await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument()); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(toastSpy).toHaveBeenCalledWith('disk full', 'error', undefined)); + expect(onDone).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-JENTRY-014: ignores an empty file selection and drops a queued file again', async () => { + const user = userEvent.setup(); + mountSheet(buildEntry()); + + fireEvent.change(multiFileInput(), { target: { files: [] } }); + expect(document.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument(); + + fireEvent.change(multiFileInput(), { target: { files: [jpeg()] } }); + await waitFor(() => expect(document.querySelector('img[src="blob:preview"]')).toBeInTheDocument()); + + const preview = document.querySelector('img[src="blob:preview"]') as HTMLElement; + await user.click(preview.parentElement!.querySelector('button') as HTMLElement); + expect(document.querySelector('img[src="blob:preview"]')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-015: queues gallery photos on a new entry and links them after the save', async () => { + const linked: unknown[] = []; + server.use(http.post('/api/journeys/entries/42/link-photo', async ({ request }) => { + linked.push(await request.json()); + return HttpResponse.json({ id: 200 }); + })); + const user = userEvent.setup(); + const { onDone } = mountSheet(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] }); + + await user.click(screen.getByRole('button', { name: 'From Gallery' })); + await user.click(screen.getAllByAltText('')[0]); + + expect(await screen.findByText('All photos already added')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(linked).toEqual([{ journey_photo_id: 200 }]); + }); + + it('FE-MOB-JENTRY-016: drops a queued gallery photo again so nothing gets linked', async () => { + let calls = 0; + server.use(http.post('/api/journeys/entries/42/link-photo', () => { + calls += 1; + return HttpResponse.json({ id: 200 }); + })); + const user = userEvent.setup(); + const { onDone } = mountSheet(buildEntry(), { galleryPhotos: [buildGalleryPhoto(200)] }); + + await user.click(screen.getByRole('button', { name: 'From Gallery' })); + await user.click(screen.getAllByAltText('')[0]); + await screen.findByText('All photos already added'); + + const tile = document.querySelector('.h-16 img')!.parentElement as HTMLElement; + await user.click(tile.querySelector('button[aria-label="Delete"]') as HTMLElement); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onDone).toHaveBeenCalledTimes(1)); + expect(calls).toBe(0); + }); + + it('FE-MOB-JENTRY-017: links a gallery photo straight away on a saved entry', async () => { + let linked = false; + server.use(http.post('/api/journeys/entries/5/link-photo', () => { + linked = true; + return HttpResponse.json(buildPhoto(200)); + })); + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5 }), { galleryPhotos: [buildGalleryPhoto(200)] }); + + await user.click(screen.getByRole('button', { name: 'From Gallery' })); + await user.click(screen.getAllByAltText('')[0]); + + await waitFor(() => expect(linked).toBe(true)); + expect(await screen.findByText('All photos already added')).toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-018: keeps the picker open when linking a gallery photo fails', async () => { + let attempts = 0; + server.use(http.post('/api/journeys/entries/5/link-photo', () => { + attempts += 1; + return new HttpResponse(null, { status: 500 }); + })); + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5 }), { galleryPhotos: [buildGalleryPhoto(200)] }); + + await user.click(screen.getByRole('button', { name: 'From Gallery' })); + await user.click(screen.getAllByAltText('')[0]); + + await waitFor(() => expect(attempts).toBe(1)); + expect(screen.queryByText('All photos already added')).not.toBeInTheDocument(); + expect(screen.getAllByAltText('')).toHaveLength(1); + }); + + it('FE-MOB-JENTRY-019: unlinks a photo removed from a saved entry', async () => { + let unlinked = false; + server.use(http.delete('/api/journeys/entries/5/photos/100', () => { + unlinked = true; + return HttpResponse.json({ ok: true }); + })); + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100)] })); + + const tile = document.querySelector('img[src="/api/photos/100/thumbnail"]')!.parentElement as HTMLElement; + await user.click(tile.querySelector('button[aria-label="Delete"]') as HTMLElement); + + await waitFor(() => expect(unlinked).toBe(true)); + expect(document.querySelector('img[src="/api/photos/100/thumbnail"]')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-020: promoting a photo to first persists the new sort order', async () => { + const patched: Array<{ id: string; body: unknown }> = []; + server.use(http.patch('/api/journeys/photos/:id', async ({ params, request }) => { + patched.push({ id: String(params.id), body: await request.json() }); + return HttpResponse.json({ ok: true }); + })); + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] })); + + await user.click(screen.getByRole('button', { name: '1st' })); + + await waitFor(() => expect(patched).toHaveLength(2)); + expect(patched).toEqual([ + { id: '101', body: { sort_order: 0 } }, + { id: '100', body: { sort_order: 1 } }, + ]); + const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src')); + expect(order[0]).toBe('/api/photos/101/thumbnail'); + }); + + it('FE-MOB-JENTRY-021: toggles mood and weather chips back off', async () => { + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5, mood: 'rough', weather: 'cold' })); + + await user.click(screen.getByRole('button', { name: 'Rough' })); + await user.click(screen.getByRole('button', { name: 'Snowy' })); + expect(screen.getByRole('button', { name: 'Rough' }).className).toContain('text-m-muted'); + expect(screen.getByRole('button', { name: 'Snowy' }).className).not.toContain('bg-m-act'); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ mood: null, weather: null }); + }); + + it('FE-MOB-JENTRY-022: picks a searched location and stores its coordinates', async () => { + server.use(http.post('/api/maps/search', () => HttpResponse.json({ + places: [ + { name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: '41.9', lng: '12.5' }, + { name: 'Roma Ostiense', lat: 41.86, lng: 12.48 }, + ], + }))); + const user = userEvent.setup(); + const { onSave } = mountSheet(buildEntry({ id: 5 })); + const input = screen.getByPlaceholderText('Search location...'); + + // The second keystroke has to clear the timer the first one armed. + fireEvent.change(input, { target: { value: 'Ro' } }); + fireEvent.change(input, { target: { value: 'Rom' } }); + + const hit = await screen.findByText('Roma Termini', {}, { timeout: 3000 }); + expect(screen.getByText('Piazza dei Cinquecento')).toBeInTheDocument(); + expect(screen.getByText('Roma Ostiense')).toBeInTheDocument(); + await user.click(hit); + + expect(screen.getByDisplayValue('Roma Termini')).toBeInTheDocument(); + expect(screen.queryByText('Piazza dei Cinquecento')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave.mock.calls[0][0]).toMatchObject({ + location_name: 'Roma Termini', location_lat: 41.9, location_lng: 12.5, + }); + }); + + it('FE-MOB-JENTRY-023: clears the suggestions for a query shorter than two characters', async () => { + server.use(http.post('/api/maps/search', () => HttpResponse.json({ + places: [{ name: 'Roma Termini', address: 'Piazza dei Cinquecento', lat: 41.9, lng: 12.5 }], + }))); + mountSheet(buildEntry({ id: 5 })); + const input = screen.getByPlaceholderText('Search location...'); + + // Focus without results must not open the dropdown. + fireEvent.focus(input); + expect(screen.queryByText('Roma Termini')).not.toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'Roma' } }); + await screen.findByText('Roma Termini', {}, { timeout: 3000 }); + + // Re-focusing with results in hand keeps the dropdown open. + fireEvent.focus(input); + expect(screen.getByText('Roma Termini')).toBeInTheDocument(); + + fireEvent.change(input, { target: { value: 'R' } }); + expect(screen.queryByText('Roma Termini')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-035: shows no suggestions when the search payload carries no places', async () => { + let calls = 0; + server.use(http.post('/api/maps/search', () => { + calls += 1; + return HttpResponse.json({}); + })); + mountSheet(buildEntry({ id: 5 })); + + fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } }); + + await waitFor(() => expect(calls).toBe(1), { timeout: 3000 }); + expect(document.querySelector('.absolute.left-0.right-0.top-full')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-036: leaves the strip untouched when the link call returns nothing', async () => { + server.use(http.post('/api/journeys/entries/5/link-photo', () => HttpResponse.json(null))); + const user = userEvent.setup(); + mountSheet( + buildEntry({ id: 5, photos: undefined as unknown as JourneyPhoto[] }), + { galleryPhotos: [buildGalleryPhoto(200)] }, + ); + + await user.click(screen.getByRole('button', { name: 'From Gallery' })); + await user.click(screen.getAllByAltText('')[0]); + + await waitFor(() => expect(screen.getAllByAltText('')).toHaveLength(1)); + expect(document.querySelector('.h-16 img')).not.toBeInTheDocument(); + expect(screen.queryByText('All photos already added')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-037: reorders photos locally even when persisting the order fails', async () => { + let attempts = 0; + server.use(http.patch('/api/journeys/photos/:id', () => { + attempts += 1; + return new HttpResponse(null, { status: 500 }); + })); + const user = userEvent.setup(); + mountSheet(buildEntry({ id: 5, photos: [buildPhoto(100), buildPhoto(101)] })); + + await user.click(screen.getByRole('button', { name: '1st' })); + + await waitFor(() => expect(attempts).toBe(2)); + const order = Array.from(document.querySelectorAll('.h-16 img')).map(i => i.getAttribute('src')); + expect(order).toEqual(['/api/photos/101/thumbnail', '/api/photos/100/thumbnail']); + }); + + it('FE-MOB-JENTRY-024: shows no suggestions when the location search fails', async () => { + let calls = 0; + server.use(http.post('/api/maps/search', () => { + calls += 1; + return new HttpResponse(null, { status: 500 }); + })); + mountSheet(buildEntry({ id: 5 })); + + fireEvent.change(screen.getByPlaceholderText('Search location...'), { target: { value: 'Roma' } }); + + await waitFor(() => expect(calls).toBe(1), { timeout: 3000 }); + expect(screen.getByDisplayValue('Roma')).toBeInTheDocument(); + expect(document.querySelector('.absolute.left-0.right-0.top-full')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-025: asks before discarding a dirty editor', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const user = userEvent.setup(); + const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' })); + + await user.type(screen.getByDisplayValue('Rome'), '!'); + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]); + + expect(confirmSpy).toHaveBeenCalledWith('You have unsaved changes. Discard them?'); + expect(onClose).not.toHaveBeenCalled(); + + confirmSpy.mockReturnValue(true); + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]); + expect(onClose).toHaveBeenCalledTimes(1); + confirmSpy.mockRestore(); + }); + + it('FE-MOB-JENTRY-026: closes an untouched editor from the header without asking', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); + const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' })); + + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0]); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + confirmSpy.mockRestore(); + }); + + it('FE-MOB-JENTRY-027: offers deleting the entry', async () => { + const user = userEvent.setup(); + const { onDelete } = mountSheet(buildEntry({ id: 5 }), { withDelete: true }); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(onDelete).toHaveBeenCalledTimes(1); + }); +}); + +describe('MJourneyEntrySheet read-only', () => { + it('FE-MOB-JENTRY-028: renders a contributor entry without any editing affordance', () => { + mountSheet(buildEntry({ + id: 5, + title: 'Roman holiday', + story: 'Gelato everywhere', + location_name: 'Rome', + location_lat: 41.9, + location_lng: 12.5, + mood: 'good', + weather: 'sunny', + tags: ['food'], + pros_cons: { pros: ['Gelato'], cons: ['Queues'] }, + photos: [buildPhoto(100), buildPhoto(101)], + }), { readOnly: true, withDelete: true, galleryPhotos: [buildGalleryPhoto(200)] }); + + expect(screen.getByText('Roman holiday')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Give this moment a name...')).not.toBeInTheDocument(); + expect(screen.getByText('Gelato everywhere')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Write your story...')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Upload photos' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'From Gallery' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '1st' })).not.toBeInTheDocument(); + expect(screen.queryAllByRole('button', { name: /Add another/ })).toHaveLength(0); + expect(screen.getByDisplayValue('Gelato')).toHaveAttribute('readonly'); + expect(screen.getByDisplayValue('Queues')).toHaveAttribute('readonly'); + expect(screen.getByDisplayValue('Rome')).toHaveAttribute('readonly'); + expect(dateField()).toBeDisabled(); + expect(timeField()).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Good' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Sunny' })).toBeDisabled(); + expect(screen.getByText('food')).toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Add tag')).not.toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-029: hides story, pros/cons and tags when a read-only entry has none', () => { + mountSheet(buildEntry({ id: 5 }), { readOnly: true }); + + expect(screen.getByText('Give this moment a name...')).toBeInTheDocument(); + expect(screen.queryByText('Pros & Cons')).not.toBeInTheDocument(); + expect(screen.queryByText('Tags')).not.toBeInTheDocument(); + expect(screen.getByText('Mood')).toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-030: closes a read-only sheet without a discard prompt', async () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const user = userEvent.setup(); + const { onClose } = mountSheet(buildEntry({ id: 5, title: 'Rome' }), { readOnly: true }); + + await user.click(screen.getAllByRole('button', { name: 'Cancel' })[1]); + + expect(confirmSpy).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + confirmSpy.mockRestore(); + }); +}); + +describe('MJourneyEntrySheet quick capture edge cases', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('FE-MOB-JENTRY-031: falls back to a generic error when the device has no geolocation', () => { + Object.defineProperty(navigator, 'geolocation', { configurable: true, value: undefined }); + mountSheet(buildEntry({ entry_time: '09:07' }), { quickCapture: true }); + + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-032: skips the location lookup when the entry already has coordinates', () => { + const getCurrentPosition = vi.fn(); + Object.defineProperty(navigator, 'geolocation', { configurable: true, value: { getCurrentPosition } }); + mountSheet( + buildEntry({ location_lat: 41.9, location_lng: 12.5, location_name: 'Rome' }), + { quickCapture: true }, + ); + + expect(getCurrentPosition).not.toHaveBeenCalled(); + expect(screen.getByDisplayValue('Rome')).toBeInTheDocument(); + }); + + it('FE-MOB-JENTRY-038: still applies the weather when reverse geocoding fails', async () => { + Object.defineProperty(navigator, 'geolocation', { + configurable: true, + value: { + getCurrentPosition: vi.fn((s: PositionCallback) => + s({ coords: { latitude: 1.35, longitude: 103.8 } } as GeolocationPosition)), + }, + }); + vi.spyOn(mapsApi, 'reverse').mockRejectedValue(new Error('nominatim down')); + vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({ + temp: 31, main: 'Clear', description: 'clear sky', type: 'current', + }); + mountSheet(buildEntry(), { quickCapture: true }); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Sunny' }).className).toContain('bg-m-act')); + expect(screen.getByPlaceholderText('Search location...')).toHaveValue(''); + }); + + it('FE-MOB-JENTRY-033: ignores a position that only arrives after the sheet is gone', async () => { + let success: PositionCallback | undefined; + Object.defineProperty(navigator, 'geolocation', { + configurable: true, + value: { getCurrentPosition: vi.fn((s: PositionCallback) => { success = s }) }, + }); + const reverse = vi.spyOn(mapsApi, 'reverse'); + const { unmount } = mountSheet(buildEntry(), { quickCapture: true }); + + unmount(); + await act(async () => { + success?.({ coords: { latitude: 1, longitude: 2 } } as GeolocationPosition); + }); + + expect(reverse).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JENTRY-034: keeps only the newest lookup when the effect restarts mid-flight', async () => { + const resolvers: Array<(v: { name: string; address: string }) => void> = []; + const failures: PositionErrorCallback[] = []; + Object.defineProperty(navigator, 'geolocation', { + configurable: true, + value: { + getCurrentPosition: vi.fn((s: PositionCallback, e: PositionErrorCallback) => { + failures.push(e); + s({ coords: { latitude: 1.35, longitude: 103.8 } } as GeolocationPosition); + }), + }, + }); + vi.spyOn(mapsApi, 'reverse').mockImplementation( + () => new Promise(resolve => { + resolvers.push(resolve as (v: { name: string; address: string }) => void); + }) + ); + vi.spyOn(weatherApi, 'getCurrent').mockResolvedValue({ + temp: 0, main: '', description: '', type: 'current', error: 'unavailable', + }); + + const base = buildEntry(); + const props = { + galleryPhotos: [], + quickCapture: true, + onClose: vi.fn(), + onSave: vi.fn(async () => 42), + onUploadPhotos: vi.fn(defaultUpload), + onDone: vi.fn(), + }; + const { rerender } = render(); + await waitFor(() => expect(resolvers).toHaveLength(1)); + + rerender(); + await waitFor(() => expect(resolvers).toHaveLength(2)); + + await act(async () => { + resolvers[0]({ name: 'Stale', address: 'Stale' }); + resolvers[1]({ name: 'Fresh', address: 'Fresh' }); + }); + + expect(await screen.findByDisplayValue('Fresh')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('Stale')).not.toBeInTheDocument(); + + // The abandoned watcher must not be able to paint an error either, and a + // failure without a message falls back to the generic label. + await act(async () => { + failures[0]({ message: 'stale failure' } as GeolocationPositionError); + failures[1]({ message: '' } as GeolocationPositionError); + }); + expect(screen.queryByText('stale failure')).not.toBeInTheDocument(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/journey/MJourneySettingsSheet.test.tsx b/client/tests/unit/mobile/journey/MJourneySettingsSheet.test.tsx new file mode 100644 index 000000000..03bdefb5d --- /dev/null +++ b/client/tests/unit/mobile/journey/MJourneySettingsSheet.test.tsx @@ -0,0 +1,380 @@ +// FE-MOB-JSET-001 to FE-MOB-JSET-030 +import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import MJourneySettingsSheet from '../../../../src/mobile/screens/journey/MJourneySettingsSheet'; +import { journeyApi } from '../../../../src/api/client'; +import { useJourneyStore } from '../../../../src/store/journeyStore'; +import type { JourneyDetail } from '../../../../src/store/journeyStore'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +type AddToast = NonNullable; +let addToast: Mock; + +const journeyStoreInitial = useJourneyStore.getState(); + +function buildJourney(over: Partial = {}): JourneyDetail { + return { + id: 12, + user_id: 1, + title: 'Japan 2026', + subtitle: 'Tokyo & Kyoto', + cover_gradient: null, + cover_image: null, + status: 'active', + created_at: 0, + updated_at: 0, + entries: [], + gallery: [], + trips: [ + { trip_id: 3, added_at: 0, title: 'Tokyo trip', start_date: '2026-05-01', end_date: '2026-05-09', cover_image: null, currency: 'EUR', place_count: 8 }, + ], + contributors: [ + { journey_id: 12, user_id: 1, role: 'owner', added_at: 0, username: 'maurice', avatar: null }, + { journey_id: 12, user_id: 2, role: 'editor', added_at: 0, username: 'julien', avatar: null }, + ], + stats: { entries: 2, photos: 5, places: 8 }, + ...over, + }; +} + +function setup(over: Partial = {}) { + const props = { + journey: buildJourney(over), + onClose: vi.fn(), + onSaved: vi.fn(), + onOpenInvite: vi.fn(), + onRefresh: vi.fn(), + }; + const view = render(); + return { ...view, props }; +} + +let updateJourney: Mock<(id: number, data: Record) => Promise>; +let deleteJourney: Mock<(id: number) => Promise>; + +/** The sheet and the confirm dialog both portal into body — scope by dialog title. */ +async function confirmButton(dialogTitle: string, label: string): Promise { + const heading = await screen.findByRole('heading', { name: dialogTitle }); + const modal = heading.closest('.trek-modal-enter') as HTMLElement; + return within(modal).getByRole('button', { name: label }); +} + +beforeEach(() => { + vi.clearAllMocks(); + addToast = vi.fn(() => 0); + window.__addToast = addToast; + updateJourney = vi.fn(async () => {}); + deleteJourney = vi.fn(async () => {}); + useJourneyStore.setState({ ...journeyStoreInitial, updateJourney, deleteJourney }, true); + vi.spyOn(journeyApi, 'getShareLink').mockResolvedValue({ link: null }); +}); + +afterEach(() => { + delete window.__addToast; + vi.restoreAllMocks(); + useJourneyStore.setState(journeyStoreInitial, true); +}); + +describe('MJourneySettingsSheet', () => { + it('FE-MOB-JSET-001: renders the journey name, subtitle and synced trips', async () => { + setup(); + expect(await screen.findByDisplayValue('Japan 2026')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Tokyo & Kyoto')).toBeInTheDocument(); + expect(screen.getByText('Tokyo trip')).toBeInTheDocument(); + expect(screen.getByText('8 places')).toBeInTheDocument(); + }); + + it('FE-MOB-JSET-002: shows the empty hint when no trip is linked', async () => { + setup({ trips: [] }); + expect(await screen.findByText('No trips linked')).toBeInTheDocument(); + }); + + it('FE-MOB-JSET-003: saving posts the edited title and subtitle and reports back', async () => { + const { props } = setup(); + fireEvent.change(await screen.findByDisplayValue('Japan 2026'), { target: { value: 'Japan 2027' } }); + fireEvent.change(screen.getByDisplayValue('Tokyo & Kyoto'), { target: { value: '' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { title: 'Japan 2027', subtitle: null })); + expect(props.onSaved).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-JSET-004: a failing save shows an error toast and keeps the sheet open', async () => { + updateJourney.mockRejectedValueOnce(new Error('nope')); + const { props } = setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Save' })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined)); + expect(props.onSaved).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-005: the save button is disabled while the title is empty', async () => { + setup(); + fireEvent.change(await screen.findByDisplayValue('Japan 2026'), { target: { value: ' ' } }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('FE-MOB-JSET-006: uploading a cover sends the file and refreshes', async () => { + const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValue({}); + const { props, container } = setup(); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + const file = new File(['x'], 'cover.jpg', { type: 'image/jpeg' }); + fireEvent.change(input, { target: { files: [file] } }); + + await waitFor(() => expect(upload).toHaveBeenCalledTimes(1)); + expect(upload.mock.calls[0][0]).toBe(12); + expect((upload.mock.calls[0][1] as FormData).get('cover')).toBe(file); + await waitFor(() => expect(props.onRefresh).toHaveBeenCalled()); + expect(addToast).toHaveBeenCalledWith('Cover updated', 'success', undefined); + }); + + it('FE-MOB-JSET-007: a failing cover upload shows an error toast', async () => { + vi.spyOn(journeyApi, 'uploadCover').mockRejectedValue(new Error('too big')); + const { props, container } = setup(); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [new File(['x'], 'cover.jpg', { type: 'image/jpeg' })] } }); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Upload failed', 'error', undefined)); + expect(props.onRefresh).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-008: choosing no file leaves the upload untouched', async () => { + const upload = vi.spyOn(journeyApi, 'uploadCover').mockResolvedValue({}); + const { container } = setup(); + const input = container.ownerDocument.querySelector('input[type="file"]') as HTMLInputElement; + fireEvent.change(input, { target: { files: [] } }); + expect(upload).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-009: Add trip loads the available trips and hides already linked ones', async () => { + vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ + trips: [{ id: 3, title: 'Tokyo trip' }, { id: 4, title: 'Oslo trip' }], + }); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' })); + + expect(await screen.findByText('Oslo trip')).toBeInTheDocument(); + // trip 3 is already linked, so it only appears once — in the synced list + expect(screen.getAllByText('Tokyo trip')).toHaveLength(1); + }); + + it('FE-MOB-JSET-010: linking a trip calls the API and refreshes', async () => { + vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 4, title: 'Oslo trip' }] }); + const addTrip = vi.spyOn(journeyApi, 'addTrip').mockResolvedValue({}); + const { props } = setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' })); + fireEvent.click(await screen.findByRole('button', { name: 'Link' })); + + await waitFor(() => expect(addTrip).toHaveBeenCalledWith(12, 4)); + expect(addToast).toHaveBeenCalledWith('Trip linked', 'success', undefined); + expect(props.onRefresh).toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-011: a failing link shows an error toast', async () => { + vi.spyOn(journeyApi, 'availableTrips').mockResolvedValue({ trips: [{ id: 4, title: 'Oslo trip' }] }); + vi.spyOn(journeyApi, 'addTrip').mockRejectedValue(new Error('boom')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' })); + fireEvent.click(await screen.findByRole('button', { name: 'Link' })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to link trip', 'error', undefined)); + }); + + it('FE-MOB-JSET-012: an empty available-trips response shows the placeholder', async () => { + vi.spyOn(journeyApi, 'availableTrips').mockRejectedValue(new Error('offline')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Add Trip' })); + expect(await screen.findByText('No trips available')).toBeInTheDocument(); + }); + + it('FE-MOB-JSET-013: unlinking a trip asks for confirmation and then removes it', async () => { + const removeTrip = vi.spyOn(journeyApi, 'removeTrip').mockResolvedValue({}); + const { props } = setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Unlink Trip' })); + fireEvent.click(await screen.findByRole('button', { name: 'Unlink' })); + + await waitFor(() => expect(removeTrip).toHaveBeenCalledWith(12, 3)); + expect(addToast).toHaveBeenCalledWith('Trip unlinked', 'success', undefined); + expect(props.onRefresh).toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-014: a failing unlink shows an error toast', async () => { + vi.spyOn(journeyApi, 'removeTrip').mockRejectedValue(new Error('boom')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Unlink Trip' })); + fireEvent.click(await screen.findByRole('button', { name: 'Unlink' })); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to unlink trip', 'error', undefined)); + }); + + it('FE-MOB-JSET-015: contributors are listed and only non-owners can be removed', async () => { + const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({}); + vi.spyOn(window, 'confirm').mockReturnValue(true); + const { props } = setup(); + + expect(await screen.findByText('maurice')).toBeInTheDocument(); + expect(screen.getByText('julien')).toBeInTheDocument(); + const removeButtons = screen.getAllByRole('button', { name: 'Remove contributor' }); + expect(removeButtons).toHaveLength(1); + + fireEvent.click(removeButtons[0]); + await waitFor(() => expect(remove).toHaveBeenCalledWith(12, 2)); + expect(addToast).toHaveBeenCalledWith('Contributor removed', 'success', undefined); + expect(props.onRefresh).toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-016: declining the confirm keeps the contributor', async () => { + const remove = vi.spyOn(journeyApi, 'removeContributor').mockResolvedValue({}); + vi.spyOn(window, 'confirm').mockReturnValue(false); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove contributor' })); + expect(remove).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-017: a failing contributor removal shows an error toast', async () => { + vi.spyOn(journeyApi, 'removeContributor').mockRejectedValue(new Error('nope')); + vi.spyOn(window, 'confirm').mockReturnValue(true); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove contributor' })); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to remove contributor', 'error', undefined)); + }); + + it('FE-MOB-JSET-018: creating a share link reveals the public URL and its toggles', async () => { + vi.spyOn(journeyApi, 'createShareLink').mockResolvedValue({ token: 'tok-1' }); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Create share link' })); + + expect(await screen.findByText(`${window.location.origin}/public/journey/tok-1`)).toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith('Share link created', 'success', undefined); + expect(screen.getAllByRole('switch')).toHaveLength(3); + }); + + it('FE-MOB-JSET-019: a failing share-link creation shows an error toast', async () => { + vi.spyOn(journeyApi, 'createShareLink').mockRejectedValue(new Error('boom')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Create share link' })); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to create link', 'error', undefined)); + }); + + it('FE-MOB-JSET-020: an existing link is loaded, copied and its permissions toggled', async () => { + vi.mocked(journeyApi.getShareLink).mockResolvedValue({ + link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: false }, + }); + const create = vi.spyOn(journeyApi, 'createShareLink').mockResolvedValue({ token: 'tok-9' }); + const writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }); + setup(); + + expect(await screen.findByText(`${window.location.origin}/public/journey/tok-9`)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Copy' })); + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/public/journey/tok-9`); + expect(await screen.findByRole('button', { name: 'Copied!' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('switch', { name: 'Map' })); + await waitFor(() => + expect(create).toHaveBeenCalledWith(12, { share_timeline: true, share_gallery: true, share_map: true }), + ); + expect(screen.getByRole('switch', { name: 'Map' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-JSET-021: a failing permission toggle rolls the switch back', async () => { + vi.mocked(journeyApi.getShareLink).mockResolvedValue({ + link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true }, + }); + vi.spyOn(journeyApi, 'createShareLink').mockRejectedValue(new Error('boom')); + setup(); + + fireEvent.click(await screen.findByRole('switch', { name: 'Gallery' })); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to update', 'error', undefined)); + expect(screen.getByRole('switch', { name: 'Gallery' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-JSET-022: removing the share link hides the URL again', async () => { + vi.mocked(journeyApi.getShareLink).mockResolvedValue({ + link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true }, + }); + const del = vi.spyOn(journeyApi, 'deleteShareLink').mockResolvedValue({}); + setup(); + + fireEvent.click(await screen.findByRole('button', { name: 'Remove share link' })); + await waitFor(() => expect(del).toHaveBeenCalledWith(12)); + expect(await screen.findByRole('button', { name: 'Create share link' })).toBeInTheDocument(); + expect(addToast).toHaveBeenCalledWith('Share link deleted', 'success', undefined); + }); + + it('FE-MOB-JSET-023: a failing share-link removal shows an error toast', async () => { + vi.mocked(journeyApi.getShareLink).mockResolvedValue({ + link: { token: 'tok-9', share_timeline: true, share_gallery: true, share_map: true }, + }); + vi.spyOn(journeyApi, 'deleteShareLink').mockRejectedValue(new Error('boom')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Remove share link' })); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to delete', 'error', undefined)); + }); + + it('FE-MOB-JSET-024: archiving an active journey flips its status', async () => { + const { props } = setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Archive Journey' })); + + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { status: 'archived' })); + expect(addToast).toHaveBeenCalledWith('Journey archived', 'success', undefined); + expect(props.onSaved).toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-025: an archived journey offers to restore it', async () => { + setup({ status: 'archived' }); + fireEvent.click(await screen.findByRole('button', { name: 'Restore Journey' })); + + await waitFor(() => expect(updateJourney).toHaveBeenCalledWith(12, { status: 'active' })); + expect(addToast).toHaveBeenCalledWith('Journey reopened', 'success', undefined); + }); + + it('FE-MOB-JSET-026: a failing archive shows an error toast', async () => { + updateJourney.mockRejectedValueOnce(new Error('nope')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Archive Journey' })); + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to save', 'error', undefined)); + }); + + it('FE-MOB-JSET-027: deleting the journey confirms and returns to the list', async () => { + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(await confirmButton('Delete Journey', 'Delete')); + + await waitFor(() => expect(deleteJourney).toHaveBeenCalledWith(12)); + expect(mockNavigate).toHaveBeenCalledWith('/journey'); + }); + + it('FE-MOB-JSET-028: a failing delete keeps the user on the sheet', async () => { + deleteJourney.mockRejectedValueOnce(new Error('nope')); + setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Delete' })); + fireEvent.click(await confirmButton('Delete Journey', 'Delete')); + + await waitFor(() => expect(addToast).toHaveBeenCalledWith('Failed to delete', 'error', undefined)); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-029: the cancel and invite buttons call back out', async () => { + const { props } = setup(); + fireEvent.click(await screen.findByRole('button', { name: 'Invite Contributor' })); + expect(props.onOpenInvite).toHaveBeenCalledTimes(1); + + const cancels = screen.getAllByRole('button', { name: 'Cancel' }); + fireEvent.click(cancels[cancels.length - 1]); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('FE-MOB-JSET-030: an existing cover is shown instead of the gradient', async () => { + setup({ cover_image: 'covers/j.jpg' }); + await waitFor(() => + expect(document.querySelector('img[src="/uploads/covers/j.jpg"]')).toBeInTheDocument(), + ); + expect(screen.getByRole('button', { name: 'Change cover' })).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/notifications/MNotificationRow.test.tsx b/client/tests/unit/mobile/notifications/MNotificationRow.test.tsx new file mode 100644 index 000000000..c5c448353 --- /dev/null +++ b/client/tests/unit/mobile/notifications/MNotificationRow.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; +import MNotificationRow from '../../../../src/mobile/screens/notifications/MNotificationRow'; +import { + useInAppNotificationStore, type InAppNotification, +} from '../../../../src/store/inAppNotificationStore'; + +// FE-MOB-NROW-001 onwards + +const markRead = vi.fn(async (_id: number) => {}); +const deleteNotification = vi.fn(async (_id: number) => {}); +const respondToBoolean = vi.fn(async (_id: number, _r: 'positive' | 'negative') => {}); + +function buildNotification(over: Partial = {}): InAppNotification { + return { + id: 3, + type: 'simple', + scope: 'trip', + target: 1, + sender_id: 2, + sender_username: 'maurice', + sender_avatar: null, + recipient_id: 1, + title_key: 'notif.title', + title_params: {}, + text_key: 'notif.text', + text_params: {}, + positive_text_key: null, + negative_text_key: null, + response: null, + navigate_text_key: null, + navigate_target: null, + is_read: false, + created_at: new Date().toISOString(), + ...over, + }; +} + +const agesAgo = (minutes: number) => new Date(Date.now() - minutes * 60000).toISOString(); + +beforeEach(() => { + resetAllStores(); + markRead.mockClear(); + deleteNotification.mockClear(); + respondToBoolean.mockClear(); + useInAppNotificationStore.setState({ markRead, deleteNotification, respondToBoolean }); +}); + +describe('MNotificationRow', () => { + it('FE-MOB-NROW-001: shows title, body, sender initial and the unread dot', () => { + const { container } = render(); + + expect(screen.getByText('notif.title')).toBeInTheDocument(); + expect(screen.getByText('notif.text')).toBeInTheDocument(); + expect(screen.getByText('M')).toBeInTheDocument(); + expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(1); + }); + + it('FE-MOB-NROW-002: a read notification drops the dot', () => { + const { container } = render(); + + expect(container.querySelectorAll('span[aria-hidden]')).toHaveLength(0); + }); + + it('FE-MOB-NROW-003: renders the sender avatar when there is one', () => { + const { container } = render( + , + ); + + expect(container.querySelector('img')).toHaveAttribute('src', '/uploads/avatars/m.jpg'); + expect(screen.queryByText('M')).not.toBeInTheDocument(); + }); + + it('FE-MOB-NROW-004: a system notification falls back to the bell tile', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.lucide-bell')).toBeInTheDocument(); + }); + + it.each([ + [0, 'just now'], + [7, '7m'], + [180, '3h'], + [60 * 24 * 2, '2d'], + ])('FE-MOB-NROW-005: %s minutes old renders as %s', (minutes, label) => { + render(); + + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it('FE-MOB-NROW-006: tapping an unread row marks it read', () => { + render(); + + fireEvent.click(screen.getByText('notif.title')); + + expect(markRead).toHaveBeenCalledWith(3); + }); + + it('FE-MOB-NROW-007: tapping an already-read row does nothing', () => { + render(); + + fireEvent.click(screen.getByText('notif.title')); + + expect(markRead).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NROW-008: the trailing button deletes without marking read', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + + expect(deleteNotification).toHaveBeenCalledWith(3); + expect(markRead).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NROW-009: a boolean notification answers through the chips', async () => { + render( + , + ); + + fireEvent.click(screen.getByText('notif.yes')); + + await waitFor(() => expect(respondToBoolean).toHaveBeenCalledWith(3, 'positive')); + // The chips sit in their own stop-propagation area, so the row is not marked read. + expect(markRead).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NROW-010: an answered boolean notification ignores further taps', () => { + render( + , + ); + + fireEvent.click(screen.getByText('notif.no')); + fireEvent.click(screen.getByText('notif.yes')); + + expect(respondToBoolean).not.toHaveBeenCalled(); + }); + + it('FE-MOB-NROW-011: a navigate notification marks read and follows its target', async () => { + render( + , + ); + + fireEvent.click(screen.getByText('notif.open')); + + await waitFor(() => expect(markRead).toHaveBeenCalledWith(3)); + }); + + it('FE-MOB-NROW-012: a boolean notification without labels renders no chips', () => { + render( + , + ); + + expect(screen.queryByText('notif.yes')).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MAirTrailConnectionSection.test.tsx b/client/tests/unit/mobile/settings/MAirTrailConnectionSection.test.tsx new file mode 100644 index 000000000..cd38dfb23 --- /dev/null +++ b/client/tests/unit/mobile/settings/MAirTrailConnectionSection.test.tsx @@ -0,0 +1,219 @@ +// FE-MOB-SETAIR-001 onwards +import { describe, it, expect, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MAirTrailConnectionSection from '../../../../src/mobile/screens/settings/MAirTrailConnectionSection'; + +interface SavedBody { + url: string; + apiKey?: string; + allowInsecureTls?: boolean; + writeEnabled?: boolean; +} + +function stubSettings(over: Record = {}) { + server.use( + http.get('/api/integrations/airtrail/settings', () => + HttpResponse.json({ url: '', allowInsecureTls: false, writeEnabled: false, connected: false, ...over }), + ), + ); +} + +function renderSection() { + return render( + <> + + + , + ); +} + +describe('MAirTrailConnectionSection', () => { + beforeEach(() => { + resetAllStores(); + stubSettings(); + }); + + it('FE-MOB-SETAIR-001: hydrates the form from the stored connection', async () => { + stubSettings({ url: 'https://air.example.com', allowInsecureTls: true, writeEnabled: true, connected: true }); + renderSection(); + + expect(await screen.findByDisplayValue('https://air.example.com')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Allow self-signed certificates' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Write changes back to AirTrail' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-002: an unconfigured instance renders empty and disconnected', async () => { + renderSection(); + + await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled()); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Bearer API key')).toHaveValue(''); + }); + + it('FE-MOB-SETAIR-003: the stored key is masked by the placeholder, never prefilled', async () => { + stubSettings({ url: 'https://air.example.com', connected: true }); + renderSection(); + + const keyInput = await screen.findByPlaceholderText('••••••••'); + expect(keyInput).toHaveValue(''); + }); + + it('FE-MOB-SETAIR-004: a failing settings fetch still ends the loading state', async () => { + server.use( + http.get('/api/integrations/airtrail/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderSection(); + + // Save stays disabled because there is no URL, but the buttons are rendered + // rather than stuck behind `loading`. + await waitFor(() => expect(screen.getByRole('button', { name: /Test connection/ })).toBeDisabled()); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-005: Save stays disabled until a URL and a key are present', async () => { + const user = userEvent.setup(); + renderSection(); + + const saveBtn = await screen.findByRole('button', { name: /Save/ }); + expect(saveBtn).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('https://airtrail.example.com'), 'https://air.example.com'); + expect(saveBtn).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('Bearer API key'), 'secret'); + expect(saveBtn).toBeEnabled(); + }); + + it('FE-MOB-SETAIR-006: an already-connected instance can be saved without retyping the key', async () => { + const user = userEvent.setup(); + let body: SavedBody | undefined; + stubSettings({ url: 'https://air.example.com', connected: true }); + server.use( + http.put('/api/integrations/airtrail/settings', async ({ request }) => { + body = (await request.json()) as SavedBody; + return HttpResponse.json({ ok: true }); + }), + http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Save/ })); + await screen.findByText('AirTrail connection saved'); + expect(body).toEqual({ url: 'https://air.example.com', allowInsecureTls: false, writeEnabled: false }); + }); + + it('FE-MOB-SETAIR-007: a typed key and the toggles are sent along and the field is cleared', async () => { + const user = userEvent.setup(); + let body: SavedBody | undefined; + server.use( + http.put('/api/integrations/airtrail/settings', async ({ request }) => { + body = (await request.json()) as SavedBody; + return HttpResponse.json({ ok: true }); + }), + http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })), + ); + renderSection(); + + await user.type(await screen.findByPlaceholderText('https://airtrail.example.com'), ' https://air.example.com '); + await user.type(screen.getByPlaceholderText('Bearer API key'), ' tok-123 '); + await user.click(screen.getByRole('switch', { name: 'Allow self-signed certificates' })); + await user.click(screen.getByRole('switch', { name: 'Write changes back to AirTrail' })); + await user.click(screen.getByRole('button', { name: /Save/ })); + + await waitFor(() => + expect(body).toEqual({ + url: 'https://air.example.com', + allowInsecureTls: true, + writeEnabled: true, + apiKey: 'tok-123', + }), + ); + await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-008: a warning from the server replaces the success toast', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com', connected: true }); + server.use( + http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ warning: 'TLS verification is off' })), + http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ connected: true })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Save/ })); + await screen.findByText('TLS verification is off'); + expect(screen.queryByText('AirTrail connection saved')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-009: a failing status probe after a successful save marks it disconnected', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com', connected: true }); + server.use( + http.put('/api/integrations/airtrail/settings', () => HttpResponse.json({ ok: true })), + http.get('/api/integrations/airtrail/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Save/ })); + await screen.findByText('Not connected'); + }); + + it('FE-MOB-SETAIR-010: a rejected save surfaces the server error message', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com', connected: true }); + server.use( + http.put('/api/integrations/airtrail/settings', () => + HttpResponse.json({ error: 'Instance unreachable' }, { status: 400 }), + ), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Save/ })); + await screen.findByText('Instance unreachable'); + }); + + it('FE-MOB-SETAIR-011: a successful test reports the flight count and flips the badge', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com' }); + server.use( + http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: true, flightCount: 12 })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Test connection/ })); + await screen.findByText('Connected — 12 flight(s) found'); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-012: a rejected test shows the returned error and keeps the badge off', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com' }); + server.use( + http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ connected: false, error: 'Bad token' })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Test connection/ })); + await screen.findByText('Bad token'); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAIR-013: a network failure during the test falls back to the generic message', async () => { + const user = userEvent.setup(); + stubSettings({ url: 'https://air.example.com' }); + server.use( + http.post('/api/integrations/airtrail/test', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + renderSection(); + + await user.click(await screen.findByRole('button', { name: /Test connection/ })); + await screen.findByText('Connection failed'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx b/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx new file mode 100644 index 000000000..e83ef5760 --- /dev/null +++ b/client/tests/unit/mobile/settings/MLlmConnectionSection.test.tsx @@ -0,0 +1,165 @@ +// FE-MOB-SETLLM-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import type { Settings } from '../../../../src/types'; +import MLlmConnectionSection from '../../../../src/mobile/screens/settings/MLlmConnectionSection'; + +function seedLlm(over: Partial = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) { + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', ...over }), + isLoaded: true, + updateSettings, + }); + return updateSettings; +} + +function renderSection() { + return render( + <> + + + , + ); +} + +describe('MLlmConnectionSection', () => { + beforeEach(() => { + resetAllStores(); + seedLlm(); + }); + + it('FE-MOB-SETLLM-001: defaults to the local provider with a base URL and no key field', () => { + renderSection(); + + expect(screen.getByText('AI parsing')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument(); + expect(screen.queryByText('Stored encrypted. Leave blank to keep the current key.')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETLLM-002: hydrates provider, model, base URL and the multimodal switch', () => { + seedLlm({ + llm_provider: 'openai', + llm_model: 'gpt-4o-mini', + llm_base_url: 'https://api.openai.com/v1', + llm_multimodal: true, + }); + renderSection(); + + expect(screen.getByRole('button', { name: /OpenAI/ })).toBeInTheDocument(); + expect(screen.getByDisplayValue('gpt-4o-mini')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://api.openai.com/v1')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Send documents as images' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-SETLLM-003: nothing is hydrated while the settings are still loading', () => { + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', llm_provider: 'anthropic', llm_model: 'claude' }), + isLoaded: false, + }); + renderSection(); + + expect(screen.getByRole('button', { name: /Local \(Ollama\)/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Save/ })).toBeDisabled(); + }); + + it('FE-MOB-SETLLM-004: a stored key drives the masked placeholder and is never prefilled', () => { + seedLlm({ llm_provider: 'anthropic', llm_api_key: '***' }); + renderSection(); + + expect(screen.getByPlaceholderText('••••••••')).toHaveValue(''); + }); + + it('FE-MOB-SETLLM-005: without a stored key the field falls back to the plain label placeholder', () => { + seedLlm({ llm_provider: 'anthropic' }); + renderSection(); + + expect(screen.getByPlaceholderText('API key')).toHaveValue(''); + }); + + it('FE-MOB-SETLLM-006: picking Anthropic hides the base URL and reveals the key field', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ })); + await user.click(await screen.findByRole('button', { name: 'Anthropic' })); + + expect(screen.queryByPlaceholderText('http://localhost:11434')).not.toBeInTheDocument(); + expect(screen.getByText('Stored encrypted. Leave blank to keep the current key.')).toBeInTheDocument(); + }); + + it('FE-MOB-SETLLM-007: picking OpenAI keeps the base URL and adds the key field', async () => { + const user = userEvent.setup(); + renderSection(); + + await user.click(screen.getByRole('button', { name: /Local \(Ollama\)/ })); + await user.click(await screen.findByRole('button', { name: 'OpenAI' })); + + expect(screen.getByPlaceholderText('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('API key')).toBeInTheDocument(); + }); + + it('FE-MOB-SETLLM-008: saving trims the inputs and omits the key when none was typed', async () => { + const user = userEvent.setup(); + const updateSettings = seedLlm({ llm_model: '', llm_base_url: '' }); + renderSection(); + + await user.type(screen.getByPlaceholderText('qwen3:8b'), ' qwen3:8b '); + await user.type(screen.getByPlaceholderText('http://localhost:11434'), ' http://ollama.local '); + await user.click(screen.getByRole('button', { name: /Save/ })); + + expect(updateSettings).toHaveBeenCalledWith({ + llm_provider: 'local', + llm_model: 'qwen3:8b', + llm_base_url: 'http://ollama.local', + llm_multimodal: false, + }); + await screen.findByText('AI settings saved'); + }); + + it('FE-MOB-SETLLM-009: a typed key is sent, the field is cleared and the mask takes over', async () => { + const user = userEvent.setup(); + const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_model: 'claude-sonnet' }); + renderSection(); + + await user.type(screen.getByPlaceholderText('API key'), ' sk-test '); + await user.click(screen.getByRole('button', { name: /Save/ })); + + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_api_key: 'sk-test' })); + await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toHaveValue('')); + }); + + it('FE-MOB-SETLLM-010: a non-local provider never persists a base URL', async () => { + const user = userEvent.setup(); + const updateSettings = seedLlm({ llm_provider: 'anthropic', llm_base_url: 'http://leftover' }); + renderSection(); + + await user.click(screen.getByRole('button', { name: /Save/ })); + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_base_url: '' })); + }); + + it('FE-MOB-SETLLM-011: the multimodal switch is part of the saved payload', async () => { + const user = userEvent.setup(); + const updateSettings = seedLlm(); + renderSection(); + + await user.click(screen.getByRole('switch', { name: 'Send documents as images' })); + await user.click(screen.getByRole('button', { name: /Save/ })); + + expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({ llm_multimodal: true })); + }); + + it('FE-MOB-SETLLM-012: a failing save shows the error toast', async () => { + const user = userEvent.setup(); + seedLlm({}, vi.fn().mockRejectedValue(new Error('nope'))); + renderSection(); + + await user.click(screen.getByRole('button', { name: /Save/ })); + await screen.findByText('Could not save AI settings'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MMobileDashOrder.test.tsx b/client/tests/unit/mobile/settings/MMobileDashOrder.test.tsx new file mode 100644 index 000000000..27ca5f180 --- /dev/null +++ b/client/tests/unit/mobile/settings/MMobileDashOrder.test.tsx @@ -0,0 +1,113 @@ +// FE-MOB-SETDORD-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { DEFAULT_APPEARANCE, type AppearanceConfig, type MobileDashToken } from '@trek/shared'; +import MMobileDashOrder from '../../../../src/mobile/screens/settings/MMobileDashOrder'; + +function buildCfg(over: Partial = {}): AppearanceConfig { + return { + ...DEFAULT_APPEARANCE, + dashboard: { ...DEFAULT_APPEARANCE.dashboard, ...over }, + }; +} + +function enableCollections() { + seedStore(useAddonStore, { + addons: [{ id: 'collections', name: 'Collections', type: 'global', icon: '', enabled: true }], + loaded: true, + }); +} + +/** The label + badge wrapper of one block row. */ +function blockRow(label: string): HTMLElement { + return screen.getByText(label).parentElement!; +} + +/** The visible block labels in DOM order. */ +function rowLabels(): string[] { + return screen.getAllByLabelText('Move up').map((btn) => { + const row = btn.closest('div')!; + return row.querySelector('span > span')!.textContent ?? ''; + }); +} + +describe('MMobileDashOrder', () => { + beforeEach(() => { + resetAllStores(); + enableCollections(); + }); + + it('FE-MOB-SETDORD-001: renders the built-in order when nothing is stored', () => { + render(); + + expect(rowLabels()).toEqual(['Trips', 'Currency', 'Collections', 'Timezones', 'Upcoming reservations']); + }); + + it('FE-MOB-SETDORD-002: a stored order wins and missing tokens are appended', () => { + render(); + + expect(rowLabels()).toEqual(['Timezones', 'Trips', 'Currency', 'Collections', 'Upcoming reservations']); + }); + + it('FE-MOB-SETDORD-003: moving a block down emits the reordered token list', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(order: MobileDashToken[]) => void>(); + render(); + + await user.click(screen.getAllByLabelText('Move down')[0]); + expect(onChange).toHaveBeenCalledWith(['currency', 'trips', 'collections', 'timezones', 'upcomingReservations']); + }); + + it('FE-MOB-SETDORD-004: moving a block up swaps it with its predecessor', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(order: MobileDashToken[]) => void>(); + render(); + + await user.click(screen.getAllByLabelText('Move up')[2]); + expect(onChange).toHaveBeenCalledWith(['trips', 'collections', 'currency', 'timezones', 'upcomingReservations']); + }); + + it('FE-MOB-SETDORD-005: the first row cannot move up and the last cannot move down', () => { + render(); + + const ups = screen.getAllByLabelText('Move up'); + const downs = screen.getAllByLabelText('Move down'); + expect(ups[0]).toBeDisabled(); + expect(ups[1]).not.toBeDisabled(); + expect(downs[downs.length - 1]).toBeDisabled(); + expect(downs[0]).not.toBeDisabled(); + }); + + it('FE-MOB-SETDORD-006: a widget switched off is marked hidden, trips never is', () => { + const cfg = buildCfg({ + mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, currency: false, timezones: false }, + }); + render(); + + expect(screen.getAllByText('Hidden')).toHaveLength(2); + const tripsRow = blockRow('Trips'); + expect(tripsRow.textContent).not.toContain('Hidden'); + }); + + it('FE-MOB-SETDORD-007: collections counts as hidden while the addon is off', () => { + seedStore(useAddonStore, { addons: [], loaded: true }); + render(); + + const collectionsRow = blockRow('Collections'); + expect(collectionsRow.textContent).toContain('Hidden'); + expect(screen.getAllByText('Hidden')).toHaveLength(1); + }); + + it('FE-MOB-SETDORD-008: upcoming reservations follows its own mobile flag', () => { + const cfg = buildCfg({ + mobile: { ...DEFAULT_APPEARANCE.dashboard.mobile, upcomingReservations: false }, + }); + render(); + + const row = blockRow('Upcoming reservations'); + expect(row.textContent).toContain('Hidden'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MMobileNavCustomizer.test.tsx b/client/tests/unit/mobile/settings/MMobileNavCustomizer.test.tsx new file mode 100644 index 000000000..048d830f9 --- /dev/null +++ b/client/tests/unit/mobile/settings/MMobileNavCustomizer.test.tsx @@ -0,0 +1,142 @@ +// FE-MOB-SETNAVC-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { act, render, screen, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { usePluginStore } from '../../../../src/store/pluginStore'; +import type { MobileNavValue } from '../../../../src/components/Settings/useMobileNavEditor'; +import MMobileNavCustomizer from '../../../../src/mobile/screens/settings/MMobileNavCustomizer'; + +const GLOBAL_ADDONS = [ + { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true }, + { id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true }, + { id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true }, + { id: 'collections', name: 'Collections', type: 'global', icon: 'bookmark', enabled: true }, +]; + +/** The section wrapper holding the rows of a zone ("In the bar" / Under "More"). */ +function zone(heading: string): HTMLElement { + return screen.getByText(heading).closest('div')!.parentElement!; +} + +function rowLabels(heading: string): string[] { + return within(zone(heading)) + .getAllByLabelText('Move up') + .map((btn) => btn.closest('div')!.querySelector('span.truncate')!.textContent ?? ''); +} + +describe('MMobileNavCustomizer', () => { + beforeEach(() => { + resetAllStores(); + seedStore(useAddonStore, { addons: GLOBAL_ADDONS, loaded: true }); + usePluginStore.setState({ plugins: [], loaded: true }); + }); + + it('FE-MOB-SETNAVC-001: an uncustomised value falls back to the built-in dock split', () => { + render(); + + expect(rowLabels('In the bar')).toEqual(['Vacay', 'Atlas']); + expect(rowLabels('Under “More”')).toEqual(['Journey', 'Collections']); + }); + + it('FE-MOB-SETNAVC-002: Dashboard is listed pinned and has no reorder buttons', () => { + render(); + + expect(screen.getByText('My Trips')).toBeInTheDocument(); + expect(screen.getByText('Pinned')).toBeInTheDocument(); + // Dashboard sits in the bar but is not one of the movable rows. + expect(rowLabels('In the bar')).not.toContain('My Trips'); + }); + + it('FE-MOB-SETNAVC-003: the bar counter includes the pinned Dashboard slot', () => { + render(); + + expect(screen.getByText('2/3')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNAVC-004: demoting a bar item emits it at the end of More', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(next: MobileNavValue) => void>(); + render(); + + await user.click(within(zone('In the bar')).getAllByLabelText('Move under “More”')[0]); + expect(onChange).toHaveBeenCalledWith({ bar: ['atlas'], more: ['journey', 'collections', 'vacay'] }); + }); + + it('FE-MOB-SETNAVC-005: promoting a More item appends it to the bar', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(next: MobileNavValue) => void>(); + render(); + + await user.click(within(zone('Under “More”')).getAllByLabelText('Move into the bar')[1]); + expect(onChange).toHaveBeenCalledWith({ bar: ['vacay', 'atlas'], more: ['journey', 'collections'] }); + }); + + it('FE-MOB-SETNAVC-006: promotion is disabled once the bar is full', () => { + render(); + + for (const btn of within(zone('Under “More”')).getAllByLabelText('Move into the bar')) { + expect(btn).toBeDisabled(); + } + }); + + it('FE-MOB-SETNAVC-007: reordering inside the bar emits the swapped id list', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(next: MobileNavValue) => void>(); + render(); + + await user.click(within(zone('In the bar')).getAllByLabelText('Move up')[1]); + expect(onChange).toHaveBeenCalledWith({ bar: ['atlas', 'vacay'], more: ['journey', 'collections'] }); + }); + + it('FE-MOB-SETNAVC-008: reordering inside More leaves the bar untouched', async () => { + const user = userEvent.setup(); + const onChange = vi.fn<(next: MobileNavValue) => void>(); + render(); + + await user.click(within(zone('Under “More”')).getAllByLabelText('Move down')[0]); + expect(onChange).toHaveBeenCalledWith({ bar: ['vacay'], more: ['atlas', 'journey', 'collections'] }); + }); + + it('FE-MOB-SETNAVC-009: the ends of a zone have their arrows disabled', () => { + render(); + + const ups = within(zone('In the bar')).getAllByLabelText('Move up'); + const downs = within(zone('In the bar')).getAllByLabelText('Move down'); + expect(ups[0]).toBeDisabled(); + expect(downs[1]).toBeDisabled(); + expect(ups[1]).not.toBeDisabled(); + }); + + it('FE-MOB-SETNAVC-010: an empty More zone shows the placeholder instead of rows', () => { + seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true }); + render(); + + expect(screen.getByText('Nothing here yet — everything fits in the bar.')).toBeInTheDocument(); + expect(within(zone('Under “More”')).queryByLabelText('Move up')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETNAVC-011: the preview mirrors the dock and only shows More when items are demoted', () => { + const { rerender } = render( + , + ); + expect(screen.getByTitle('More')).toBeInTheDocument(); + expect(screen.getByTitle('My Trips')).toBeInTheDocument(); + expect(screen.getByTitle('Vacay')).toBeInTheDocument(); + + act(() => seedStore(useAddonStore, { addons: GLOBAL_ADDONS.slice(0, 2), loaded: true })); + rerender(); + expect(screen.queryByTitle('More')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETNAVC-012: page plugins appear as nav items under More', () => { + usePluginStore.setState({ + plugins: [{ id: 'todos', name: 'Trip To-Dos', type: 'page', icon: 'list' }] as never, + loaded: true, + }); + render(); + + expect(rowLabels('Under “More”')).toContain('Trip To-Dos'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MPhotoProvidersSection.test.tsx b/client/tests/unit/mobile/settings/MPhotoProvidersSection.test.tsx new file mode 100644 index 000000000..9e202a331 --- /dev/null +++ b/client/tests/unit/mobile/settings/MPhotoProvidersSection.test.tsx @@ -0,0 +1,351 @@ +// FE-MOB-PHOTOPROV-001 onwards +import { describe, it, expect, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildUser } from '../../../helpers/factories'; +import { server } from '../../../helpers/msw/server'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MPhotoProvidersSection from '../../../../src/mobile/screens/settings/MPhotoProvidersSection'; + +// Fields are deliberately declared out of sort_order so the component's own +// sorting is what puts them on screen in the url → api_key → skip_ssl order. +const immich = { + id: 'immich', + name: 'Immich', + type: 'photo_provider', + enabled: true, + config: { + settings_get: '/addons/immich/settings', + settings_put: '/addons/immich/settings', + status_get: '/addons/immich/status', + test_post: '/addons/immich/test', + }, + fields: [ + { key: 'skip_ssl', label: 'skipSSLVerification', input_type: 'checkbox', required: false, secret: false, settings_key: 'skip_ssl', payload_key: 'skip_ssl', sort_order: 3 }, + { key: 'api_key', label: 'providerApiKey', input_type: 'text', placeholder: null, required: true, secret: true, settings_key: 'api_key', payload_key: 'api_key', sort_order: 2 }, + { key: 'url', label: 'providerUrl', input_type: 'text', placeholder: 'https://immich.example.com', hint: 'providerUrlHintSynology', required: true, secret: false, settings_key: 'url', payload_key: 'url', sort_order: 1 }, + ], +}; + +// Only a GET-shaped test route and no settings_put / status_get: Save must stay +// dead and the connection test has to fall back to the GET endpoint. +const piwigo = { + id: 'piwigo', + name: 'Piwigo', + type: 'photo_provider', + enabled: true, + config: { + settings_get: '/addons/piwigo/settings', + test_get: '/addons/piwigo/test', + }, + fields: [ + { key: 'auto_upload', label: 'immichAutoUpload', input_type: 'checkbox', required: false, secret: false, settings_key: 'auto_upload', payload_key: 'auto_upload', sort_order: 1 }, + { key: 'username', label: 'providerUsername', input_type: 'text', placeholder: 'user', required: false, secret: false, settings_key: 'username', payload_key: 'username', sort_order: 2 }, + ], +}; + +// Config values of the wrong type must be ignored, leaving the provider with no +// usable route at all — neither button may be clickable. +const bare = { + id: 'bare', + name: '', + type: 'photo_provider', + enabled: true, + config: { settings_get: 7, settings_put: null, status_get: false }, + fields: [ + { key: 'token', label: 'providerPassword', input_type: 'text', required: false, secret: true, settings_key: null, payload_key: null, sort_order: 1 }, + ], +}; + +type Addon = { id: string; type: string; enabled: boolean }; + +function seedProviders(providers: unknown[]): void { + seedStore(useAddonStore, { addons: providers as Addon[] }); +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + seedStore(useAddonStore, { addons: [] }); + server.use( + http.get('/api/addons/immich/settings', () => + HttpResponse.json({ url: 'https://photos.example.com', api_key: 'never-sent-back', skip_ssl: 1, connected: false }), + ), + http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: false })), + http.put('/api/addons/immich/settings', () => HttpResponse.json({ success: true })), + http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: true })), + http.get('/api/addons/piwigo/settings', () => HttpResponse.json({})), + http.get('/api/addons/piwigo/test', () => HttpResponse.json({ connected: true })), + ); +}); + +describe('MPhotoProvidersSection', () => { + it('FE-MOB-PHOTOPROV-001: renders nothing while the memories addon is off', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('FE-MOB-PHOTOPROV-002: renders nothing when memories is on but no provider is active', async () => { + seedStore(useAddonStore, { + addons: [{ ...piwigo, enabled: false }] as unknown as Addon[], + isEnabled: () => true, + }); + const { container } = render(); + await waitFor(() => expect(container).toBeEmptyDOMElement()); + expect(screen.queryByText('Piwigo')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-003: renders one card per provider with the fields in sort_order', async () => { + seedProviders([immich, piwigo]); + render(); + + await screen.findByText('Immich'); + expect(screen.getByText('Piwigo')).toBeInTheDocument(); + + const immichCard = screen.getByText('Immich').closest('section') as HTMLElement; + const labels = within(immichCard) + .getAllByText(/Server URL|API Key|Skip SSL certificate verification/) + .map(el => el.textContent); + expect(labels).toEqual(['Server URL', 'API Key', 'Skip SSL certificate verification']); + // The hint of the url field is translated, not printed as its key. + expect(screen.getByText(/Include the Photos app path in the URL/)).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-004: hydrates non-secret fields, leaves the secret one blank and coerces non-strings', async () => { + seedProviders([immich]); + render(); + + const urlInput = await screen.findByDisplayValue('https://photos.example.com'); + expect(urlInput).toBeInTheDocument(); + // The API key came back from the server but must never be prefilled. + expect(screen.queryByDisplayValue('never-sent-back')).not.toBeInTheDocument(); + // skip_ssl arrived as the number 1 — stringified, so it is not the literal 'true'. + expect(screen.getByRole('switch', { name: 'Skip SSL certificate verification' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-PHOTOPROV-005: missing settings values fall back to empty text and an off checkbox', async () => { + seedProviders([piwigo]); + render(); + + await screen.findByText('Piwigo'); + await waitFor(() => + expect(screen.getByRole('switch', { name: 'Mirror journey photos to Immich on upload' })).toHaveAttribute('aria-checked', 'false'), + ); + expect(screen.getByPlaceholderText('user')).toHaveValue(''); + }); + + it('FE-MOB-PHOTOPROV-006: a connected provider masks the empty secret field', async () => { + server.use( + http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', connected: true })), + http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: true })), + ); + seedProviders([immich]); + render(); + + await screen.findByText('Immich'); + await waitFor(() => expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument()); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-007: Save stays disabled until every required field has a value', async () => { + const user = userEvent.setup(); + seedProviders([immich]); + render(); + + await screen.findByDisplayValue('https://photos.example.com'); + const save = screen.getByRole('button', { name: 'Save' }); + expect(save).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(''), ' fresh-key '); + await waitFor(() => expect(save).toBeEnabled()); + }); + + it('FE-MOB-PHOTOPROV-008: Save trims values, sends the checkbox as a boolean and re-reads the status', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + let statusCalls = 0; + server.use( + http.put('/api/addons/immich/settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + http.get('/api/addons/immich/status', () => { + statusCalls += 1; + return HttpResponse.json({ connected: true }); + }), + ); + seedProviders([immich]); + render(); + + await screen.findByDisplayValue('https://photos.example.com'); + await user.type(screen.getByPlaceholderText(''), ' fresh-key '); + await user.click(screen.getByRole('switch', { name: 'Skip SSL certificate verification' })); + const save = screen.getByRole('button', { name: 'Save' }); + await waitFor(() => expect(save).toBeEnabled()); + await user.click(save); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body).toEqual({ url: 'https://photos.example.com', api_key: 'fresh-key', skip_ssl: true }); + // The status endpoint is polled once on mount and again right after the save. + await waitFor(() => expect(statusCalls).toBeGreaterThanOrEqual(2)); + expect(await screen.findByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-009: an untouched secret field is left out of the payload entirely', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.get('/api/addons/immich/settings', () => HttpResponse.json({ url: 'https://photos.example.com', connected: true })), + http.get('/api/addons/immich/status', () => HttpResponse.json({ connected: true })), + http.put('/api/addons/immich/settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + // A provider whose secret is optional, so Save is reachable without typing one. + seedProviders([{ ...immich, fields: immich.fields.map(f => ({ ...f, required: f.key === 'url' })) }]); + render(); + + await screen.findByDisplayValue('https://photos.example.com'); + const save = screen.getByRole('button', { name: 'Save' }); + await waitFor(() => expect(save).toBeEnabled()); + await user.click(save); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body).toEqual({ url: 'https://photos.example.com', skip_ssl: false }); + }); + + it('FE-MOB-PHOTOPROV-010: a successful save toasts the provider name', async () => { + const user = userEvent.setup(); + seedProviders([{ ...immich, fields: [immich.fields[2]] }]); + render(<>); + + await screen.findByDisplayValue('https://photos.example.com'); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(await screen.findByText('Immich settings saved')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-011: a rejected save toasts the error and re-enables the button', async () => { + const user = userEvent.setup(); + server.use(http.put('/api/addons/immich/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + seedProviders([{ ...immich, fields: [immich.fields[2]] }]); + render(<>); + + await screen.findByDisplayValue('https://photos.example.com'); + const save = screen.getByRole('button', { name: 'Save' }); + await user.click(save); + + expect(await screen.findByText('Could not save Immich settings')).toBeInTheDocument(); + await waitFor(() => expect(save).toBeEnabled()); + }); + + it('FE-MOB-PHOTOPROV-012: a passing test flips the badge to Connected and toasts', async () => { + const user = userEvent.setup(); + let payload: Record | null = null; + server.use( + http.post('/api/addons/immich/test', async ({ request }) => { + payload = (await request.json()) as Record; + return HttpResponse.json({ connected: true }); + }), + ); + seedProviders([immich]); + render(<>); + + await screen.findByDisplayValue('https://photos.example.com'); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(await screen.findByText('Connected to Immich')).toBeInTheDocument(); + expect(screen.getByText('Connected')).toBeInTheDocument(); + // The test posts the form as it stands, not the persisted settings. + expect(payload).toEqual({ url: 'https://photos.example.com', skip_ssl: false }); + }); + + it('FE-MOB-PHOTOPROV-013: a test answering connected:false surfaces the server error text', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ connected: false, error: 'Auth failed' }))); + seedProviders([immich]); + render(<>); + + await screen.findByText('Immich'); + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(await screen.findByText(/Could not connect to Immich : Auth failed/)).toBeInTheDocument(); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-014: a failing test request toasts the generic connection error', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/addons/immich/test', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + seedProviders([immich]); + render(<>); + + await screen.findByText('Immich'); + const test = screen.getByRole('button', { name: 'Test' }); + await user.click(test); + + expect(await screen.findByText('Could not connect to Immich')).toBeInTheDocument(); + await waitFor(() => expect(test).toBeEnabled()); + }); + + it('FE-MOB-PHOTOPROV-015: a provider with only test_get falls back to the GET route and cannot be saved', async () => { + const user = userEvent.setup(); + let getCalled = false; + server.use( + http.get('/api/addons/piwigo/test', () => { + getCalled = true; + return HttpResponse.json({ connected: true }); + }), + ); + seedProviders([piwigo]); + render(); + + await screen.findByText('Piwigo'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'Test' })); + + await waitFor(() => expect(getCalled).toBe(true)); + expect(await screen.findByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-016: config entries of the wrong type leave both actions disabled', async () => { + seedProviders([bare]); + render(); + + // No name → the card falls back to the addon id. + await screen.findByText('bare'); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled(); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + }); + + it('FE-MOB-PHOTOPROV-017: a failing settings load leaves the fields empty instead of crashing', async () => { + server.use( + http.get('/api/addons/immich/settings', () => HttpResponse.json({ error: 'down' }, { status: 500 })), + http.get('/api/addons/immich/status', () => HttpResponse.json({ error: 'down' }, { status: 500 })), + ); + seedProviders([immich]); + render(); + + await screen.findByText('Immich'); + expect(screen.getByPlaceholderText('https://immich.example.com')).toHaveValue(''); + await waitFor(() => expect(screen.getByText('Not connected')).toBeInTheDocument()); + }); + + it('FE-MOB-PHOTOPROV-018: the checkbox toggle flips both ways', async () => { + const user = userEvent.setup(); + seedProviders([immich]); + render(); + + await screen.findByText('Immich'); + const toggle = screen.getByRole('switch', { name: 'Skip SSL certificate verification' }); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + await user.click(toggle); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MScopeGroupPicker.test.tsx b/client/tests/unit/mobile/settings/MScopeGroupPicker.test.tsx new file mode 100644 index 000000000..839b8fc5c --- /dev/null +++ b/client/tests/unit/mobile/settings/MScopeGroupPicker.test.tsx @@ -0,0 +1,109 @@ +// FE-MOB-SETSCP-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; +import { ALL_SCOPES } from '../../../../src/api/oauthScopes'; +import MScopeGroupPicker from '../../../../src/mobile/screens/settings/MScopeGroupPicker'; + +const TRIP_SCOPES = ['trips:read', 'trips:write', 'trips:delete', 'trips:share']; + +describe('MScopeGroupPicker', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('FE-MOB-SETSCP-001: renders one collapsed card per scope group', () => { + render(); + + expect(screen.getByRole('button', { name: /^Trips/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^Journey/ })).toBeInTheDocument(); + // Collapsed: no individual scope rows yet. + expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETSCP-002: Select all emits every known scope', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Select all' })); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0]).toEqual(ALL_SCOPES); + }); + + it('FE-MOB-SETSCP-003: with everything selected the button flips to Deselect all and emits []', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('FE-MOB-SETSCP-004: expanding a group reveals its scope labels and descriptions', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Trips/ })); + + expect(screen.getByText('View trips & itineraries')).toBeInTheDocument(); + expect(screen.getByText('Read trips, days, day notes, and members')).toBeInTheDocument(); + }); + + it('FE-MOB-SETSCP-005: expanding twice collapses the group again', async () => { + const user = userEvent.setup(); + render(); + + const header = screen.getByRole('button', { name: /^Trips/ }); + await user.click(header); + expect(screen.getByText('View trips & itineraries')).toBeInTheDocument(); + + await user.click(header); + expect(screen.queryByText('View trips & itineraries')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETSCP-006: the group selector adds every scope of that group', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Select all Trips' })); + expect(onChange).toHaveBeenCalledWith(['geo:read', ...TRIP_SCOPES]); + }); + + it('FE-MOB-SETSCP-007: a fully selected group offers deselect and keeps the other groups', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Deselect all Trips' })); + expect(onChange).toHaveBeenCalledWith(['geo:read']); + }); + + it('FE-MOB-SETSCP-008: a partially selected group shows the n/total counter', () => { + render(); + + expect(screen.getByText('(2/4)')).toBeInTheDocument(); + }); + + it('FE-MOB-SETSCP-009: tapping an unselected scope row adds it', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /^Trips/ })); + await user.click(screen.getByText('View trips & itineraries').closest('button')!); + expect(onChange).toHaveBeenCalledWith(['trips:read']); + }); + + it('FE-MOB-SETSCP-010: tapping a selected scope row removes it again', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: /^Trips/ })); + await user.click(screen.getByText('View trips & itineraries').closest('button')!); + expect(onChange).toHaveBeenCalledWith(['trips:write']); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsAccount.test.tsx b/client/tests/unit/mobile/settings/MSettingsAccount.test.tsx new file mode 100644 index 000000000..16d860841 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsAccount.test.tsx @@ -0,0 +1,1166 @@ +// FE-MOB-SETACC-001 to FE-MOB-SETACC-070 +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { startRegistration } from '@simplewebauthn/browser'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildUser, buildAppConfig } from '../../../helpers/factories'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import type { PasskeyCredential } from '../../../../src/api/client'; +import type { UserWithOidc } from '../../../../src/types'; +import MSettingsAccount from '../../../../src/mobile/screens/settings/MSettingsAccount'; + +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +vi.mock('@simplewebauthn/browser', () => ({ startRegistration: vi.fn() })); + +const webauthn = startRegistration as unknown as ReturnType; + +const BACKUP_KEY = 'trek_mfa_backup_codes_pending'; + +function appConfig(extra: Record = {}) { + return http.get('/api/auth/app-config', () => HttpResponse.json({ ...buildAppConfig(), ...extra })); +} + +function passkeyList(credentials: PasskeyCredential[] = []) { + return http.get('/api/auth/passkey/credentials', () => HttpResponse.json({ credentials })); +} + +function buildCredential(overrides: Partial = {}): PasskeyCredential { + return { + id: 1, + name: 'iPhone', + device_type: 'platform', + backed_up: true, + created_at: '2025-03-04 10:00:00', + last_used_at: null, + ...overrides, + }; +} + +function renderAccount(initialEntries: string[] = ['/settings']) { + return render( + <> + + + , + { initialEntries }, + ); +} + +function fileInput(): HTMLInputElement { + return document.querySelector('input[type="file"]') as HTMLInputElement; +} + +function passwordInputs(): HTMLInputElement[] { + return Array.from(document.querySelectorAll('input[type="password"]')); +} + +beforeEach(() => { + resetAllStores(); + navigateMock.mockReset(); + webauthn.mockReset(); + server.use(appConfig(), passkeyList()); + seedStore(useAuthStore, { + isAuthenticated: true, + user: buildUser({ username: 'maurice', email: 'maurice@example.com', role: 'user' }), + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +// ── Profile & identity ─────────────────────────────────────────────────────── + +describe('MSettingsAccount – profile', () => { + it('FE-MOB-SETACC-001: prefills username and email from the signed-in user', () => { + renderAccount(); + + expect(screen.getByDisplayValue('maurice')).toBeInTheDocument(); + expect(screen.getByDisplayValue('maurice@example.com')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-002: an admin account is labelled Administrator', () => { + seedStore(useAuthStore, { user: buildUser({ username: 'root', role: 'admin' }) }); + renderAccount(); + + expect(screen.getByText('Administrator')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-003: an OIDC-linked account shows the SSO badge and the stripped issuer', () => { + seedStore(useAuthStore, { + user: buildUser({ username: 'sso', oidc_issuer: 'https://auth.example.com/' } as Partial), + }); + renderAccount(); + + expect(screen.getByText('SSO')).toBeInTheDocument(); + expect(screen.getByText(/Linked with auth\.example\.com$/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-004: without an avatar the initial is shown and there is no remove button', () => { + renderAccount(); + + expect(screen.getByText('M')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Remove Profile Picture' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-005: an existing avatar renders as an image with a remove button', () => { + seedStore(useAuthStore, { + user: buildUser({ username: 'maurice', avatar_url: 'https://cdn.example.com/a.jpg' }), + }); + renderAccount(); + + const img = document.querySelector('img') as HTMLImageElement; + expect(img.src).toBe('https://cdn.example.com/a.jpg'); + expect(screen.getByRole('button', { name: 'Remove Profile Picture' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-006: the camera button opens the hidden file input', async () => { + const user = userEvent.setup(); + const clickSpy = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {}); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Upload Profile Picture' })); + expect(clickSpy).toHaveBeenCalled(); + clickSpy.mockRestore(); + }); + + it('FE-MOB-SETACC-007: picking a file uploads it and toasts success', async () => { + const uploadAvatar = vi.fn().mockResolvedValue({ avatar_url: '/uploads/a.jpg' }); + seedStore(useAuthStore, { uploadAvatar }); + renderAccount(); + + const file = new File(['x'], 'avatar.png', { type: 'image/png' }); + fireEvent.change(fileInput(), { target: { files: [file] } }); + + await screen.findByText('Profile picture updated'); + expect(uploadAvatar).toHaveBeenCalledWith(file); + }); + + it('FE-MOB-SETACC-008: a failing upload toasts the upload error', async () => { + seedStore(useAuthStore, { uploadAvatar: vi.fn().mockRejectedValue(new Error('too large')) }); + renderAccount(); + + fireEvent.change(fileInput(), { target: { files: [new File(['x'], 'a.png', { type: 'image/png' })] } }); + + await screen.findByText('Upload failed'); + }); + + it('FE-MOB-SETACC-009: a change event without a file is ignored', () => { + const uploadAvatar = vi.fn(); + seedStore(useAuthStore, { uploadAvatar }); + renderAccount(); + + fireEvent.change(fileInput(), { target: { files: [] } }); + + expect(uploadAvatar).not.toHaveBeenCalled(); + }); + + it('FE-MOB-SETACC-010: the remove button deletes the avatar and toasts', async () => { + const user = userEvent.setup(); + const deleteAvatar = vi.fn().mockResolvedValue(undefined); + seedStore(useAuthStore, { + user: buildUser({ username: 'maurice', avatar_url: '/uploads/a.jpg' }), + deleteAvatar, + }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Remove Profile Picture' })); + + expect(deleteAvatar).toHaveBeenCalled(); + await screen.findByText('Profile picture removed'); + }); + + it('FE-MOB-SETACC-011: a failing avatar removal toasts the error', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { + user: buildUser({ username: 'maurice', avatar_url: '/uploads/a.jpg' }), + deleteAvatar: vi.fn().mockRejectedValue(new Error('nope')), + }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Remove Profile Picture' })); + + await screen.findByText('Upload failed'); + }); + + it('FE-MOB-SETACC-012: Save sends the edited fields through updateProfile', async () => { + const user = userEvent.setup(); + const updateProfile = vi.fn().mockResolvedValue(undefined); + seedStore(useAuthStore, { updateProfile }); + renderAccount(); + + const username = screen.getByDisplayValue('maurice'); + await user.clear(username); + await user.type(username, 'mo'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(updateProfile).toHaveBeenCalledWith({ username: 'mo', email: 'maurice@example.com' }); + await screen.findByText('Profile saved'); + }); + + it('FE-MOB-SETACC-013a: the email field is editable and reaches updateProfile', async () => { + const user = userEvent.setup(); + const updateProfile = vi.fn().mockResolvedValue(undefined); + seedStore(useAuthStore, { updateProfile }); + renderAccount(); + + const email = screen.getByDisplayValue('maurice@example.com'); + await user.clear(email); + await user.type(email, 'mo@example.com'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(updateProfile).toHaveBeenCalledWith({ username: 'maurice', email: 'mo@example.com' }); + }); + + it('FE-MOB-SETACC-013b: a rejection that is not an Error falls back to the generic message', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { updateProfile: vi.fn().mockRejectedValue('boom') }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + await screen.findByText('Error'); + }); + + it('FE-MOB-SETACC-013c: without a signed-in user both profile fields start empty', () => { + seedStore(useAuthStore, { user: null }); + renderAccount(); + + const username = document.querySelector('input:not([type])') as HTMLInputElement; + const email = document.querySelector('input[type="email"]') as HTMLInputElement; + expect(username.value).toBe(''); + expect(email.value).toBe(''); + expect(screen.getByText('User')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-013: a rejected profile save surfaces the error message', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { updateProfile: vi.fn().mockRejectedValue(new Error('Email already taken')) }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + await screen.findByText('Email already taken'); + }); + + it('FE-MOB-SETACC-014: Save is disabled while the request is in flight', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { updateProfile: vi.fn().mockReturnValue(new Promise(() => {})) }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); +}); + +// ── Password ───────────────────────────────────────────────────────────────── + +describe('MSettingsAccount – password', () => { + it('FE-MOB-SETACC-015: the card is hidden when the instance runs OIDC-only', async () => { + server.use(appConfig({ oidc_only_mode: true })); + renderAccount(); + + await waitFor(() => expect(screen.queryByText('Change Password')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-016: a failing app-config request leaves the card in place', async () => { + server.use(http.get('/api/auth/app-config', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); + renderAccount(); + + // Both app-config consumers swallow the error: the passkey card settles to + // "off" and the password card stays visible. + await waitFor(() => expect(screen.queryByText('Passkeys')).not.toBeInTheDocument()); + expect(screen.getByText('Change Password')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-017: an empty current password is rejected', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Current password is required'); + }); + + it('FE-MOB-SETACC-018: an empty new password is rejected', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.type(screen.getByPlaceholderText('Current password'), 'old-secret'); + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Please enter current and new password'); + }); + + it('FE-MOB-SETACC-019: a new password below eight characters is rejected', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.type(screen.getByPlaceholderText('Current password'), 'old-secret'); + await user.type(screen.getByPlaceholderText('New password'), 'short'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'short'); + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Password must be at least 8 characters'); + }); + + it('FE-MOB-SETACC-020: a mismatching confirmation is rejected', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.type(screen.getByPlaceholderText('Current password'), 'old-secret'); + await user.type(screen.getByPlaceholderText('New password'), 'NewPassword1!'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'NewPassword2!'); + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Passwords do not match'); + }); + + it('FE-MOB-SETACC-021: a valid change hits the API and clears the three fields', async () => { + const user = userEvent.setup(); + let payload: { current_password?: string; new_password?: string } | null = null; + server.use( + http.put('/api/auth/me/password', async ({ request }) => { + payload = (await request.json()) as { current_password?: string; new_password?: string }; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + await user.type(screen.getByPlaceholderText('Current password'), 'old-secret'); + await user.type(screen.getByPlaceholderText('New password'), 'NewPassword1!'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'NewPassword1!'); + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Password changed successfully'); + expect(payload).toEqual({ current_password: 'old-secret', new_password: 'NewPassword1!' }); + await waitFor(() => expect(passwordInputs().every((i) => i.value === '')).toBe(true)); + }); + + it('FE-MOB-SETACC-022: a rejected change shows the server message', async () => { + const user = userEvent.setup(); + server.use( + http.put('/api/auth/me/password', () => HttpResponse.json({ error: 'Wrong password' }, { status: 400 })), + ); + renderAccount(); + + await user.type(screen.getByPlaceholderText('Current password'), 'nope'); + await user.type(screen.getByPlaceholderText('New password'), 'NewPassword1!'); + await user.type(screen.getByPlaceholderText('Confirm new password'), 'NewPassword1!'); + await user.click(screen.getByRole('button', { name: 'Update password' })); + + await screen.findByText('Wrong password'); + }); +}); + +// ── Two-factor authentication ──────────────────────────────────────────────── + +describe('MSettingsAccount – MFA', () => { + const mfaSetupHandler = http.post('/api/auth/mfa/setup', () => + HttpResponse.json({ qr_svg: '', secret: 'JBSWY3DPEHPK3PXP' }), + ); + + async function openSetup(user: ReturnType) { + server.use(mfaSetupHandler); + renderAccount(); + await user.click(screen.getByRole('button', { name: 'Set up authenticator' })); + await screen.findByText('JBSWY3DPEHPK3PXP'); + } + + it('FE-MOB-SETACC-023: demo mode replaces the controls with the demo notice', () => { + seedStore(useAuthStore, { demoMode: true }); + renderAccount(); + + expect(screen.getByText('Not available in demo mode')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Set up authenticator' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-024: the ?mfa=required entry shows the policy banner', () => { + renderAccount(['/settings?mfa=required']); + + expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-025: an instance-wide MFA requirement shows the same banner', () => { + seedStore(useAuthStore, { appRequireMfa: true }); + renderAccount(); + + expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-026: the banner stays hidden once 2FA is on', () => { + seedStore(useAuthStore, { appRequireMfa: true, user: buildUser({ username: 'maurice', mfa_enabled: true }) }); + renderAccount(); + + expect(screen.queryByText(/requires two-factor authentication/i)).not.toBeInTheDocument(); + expect(screen.getByText('2FA is enabled on your account.')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-027: setup fetches the QR code and the manual secret', async () => { + const user = userEvent.setup(); + await openSetup(user); + + expect(document.querySelector('svg[data-qr="1"]')).not.toBeNull(); + expect(screen.getByPlaceholderText('6-digit code')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-028: the code field drops non-digits and caps at eight', async () => { + const user = userEvent.setup(); + await openSetup(user); + + const code = screen.getByPlaceholderText('6-digit code') as HTMLInputElement; + await user.type(code, 'ab123456789'); + expect(code.value).toBe('12345678'); + }); + + it('FE-MOB-SETACC-029: Enable stays disabled below six digits', async () => { + const user = userEvent.setup(); + await openSetup(user); + + await user.type(screen.getByPlaceholderText('6-digit code'), '12345'); + expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('6-digit code'), '6'); + expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeEnabled(); + }); + + it('FE-MOB-SETACC-030: cancelling setup returns to the setup button', async () => { + const user = userEvent.setup(); + await openSetup(user); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByText('JBSWY3DPEHPK3PXP')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Set up authenticator' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-031: a failing setup shows the server message', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/auth/mfa/setup', () => HttpResponse.json({ error: 'MFA is off' }, { status: 400 }))); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Set up authenticator' })); + + await screen.findByText('MFA is off'); + }); + + it('FE-MOB-SETACC-032: enabling stores the backup codes and renders them', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/auth/mfa/enable', () => HttpResponse.json({ backup_codes: ['AAAA-1111', 'BBBB-2222'] })), + http.get('/api/auth/me', () => HttpResponse.json({ user: buildUser({ username: 'maurice', mfa_enabled: true }) })), + ); + await openSetup(user); + + await user.type(screen.getByPlaceholderText('6-digit code'), '123456'); + await user.click(screen.getByRole('button', { name: 'Enable 2FA' })); + + await screen.findByText('Two-factor authentication enabled'); + expect(screen.getByText(/AAAA-1111/)).toBeInTheDocument(); + expect(JSON.parse(sessionStorage.getItem(BACKUP_KEY) || '[]')).toEqual(['AAAA-1111', 'BBBB-2222']); + }); + + it('FE-MOB-SETACC-033: enabling without backup codes leaves nothing stored', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/auth/mfa/enable', () => HttpResponse.json({ success: true }))); + await openSetup(user); + + await user.type(screen.getByPlaceholderText('6-digit code'), '123456'); + await user.click(screen.getByRole('button', { name: 'Enable 2FA' })); + + await screen.findByText('Two-factor authentication enabled'); + expect(sessionStorage.getItem(BACKUP_KEY)).toBeNull(); + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-034: a rejected enable shows the server message', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/auth/mfa/enable', () => HttpResponse.json({ error: 'Invalid code' }, { status: 400 }))); + await openSetup(user); + + await user.type(screen.getByPlaceholderText('6-digit code'), '123456'); + await user.click(screen.getByRole('button', { name: 'Enable 2FA' })); + + await screen.findByText('Invalid code'); + }); + + it('FE-MOB-SETACC-035: the disable form needs both a password and a six-digit code', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'maurice', mfa_enabled: true }) }); + renderAccount(); + + expect(screen.getByRole('button', { name: 'Disable 2FA' })).toBeDisabled(); + + const mfaPassword = passwordInputs()[passwordInputs().length - 1]; + await user.type(mfaPassword, 'my-password'); + await user.type(screen.getByPlaceholderText('6-digit code'), '12345'); + expect(screen.getByRole('button', { name: 'Disable 2FA' })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('6-digit code'), '6'); + expect(screen.getByRole('button', { name: 'Disable 2FA' })).toBeEnabled(); + }); + + it('FE-MOB-SETACC-036: disabling calls the API and drops the stored backup codes', async () => { + const user = userEvent.setup(); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + seedStore(useAuthStore, { user: buildUser({ username: 'maurice', mfa_enabled: true }) }); + let payload: { password?: string; code?: string } | null = null; + server.use( + http.post('/api/auth/mfa/disable', async ({ request }) => { + payload = (await request.json()) as { password?: string; code?: string }; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + await screen.findByText(/AAAA-1111/); + await user.type(passwordInputs()[passwordInputs().length - 1], 'my-password'); + await user.type(screen.getByPlaceholderText('6-digit code'), '123456'); + await user.click(screen.getByRole('button', { name: 'Disable 2FA' })); + + await screen.findByText('Two-factor authentication disabled'); + expect(payload).toEqual({ password: 'my-password', code: '123456' }); + expect(sessionStorage.getItem(BACKUP_KEY)).toBeNull(); + }); + + it('FE-MOB-SETACC-037: a rejected disable shows the server message', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'maurice', mfa_enabled: true }) }); + server.use( + http.post('/api/auth/mfa/disable', () => HttpResponse.json({ error: 'Code expired' }, { status: 400 })), + ); + renderAccount(); + + await user.type(passwordInputs()[passwordInputs().length - 1], 'my-password'); + await user.type(screen.getByPlaceholderText('6-digit code'), '123456'); + await user.click(screen.getByRole('button', { name: 'Disable 2FA' })); + + await screen.findByText('Code expired'); + }); +}); + +// ── Backup codes ───────────────────────────────────────────────────────────── + +describe('MSettingsAccount – backup codes', () => { + beforeEach(() => { + seedStore(useAuthStore, { user: buildUser({ username: 'maurice', mfa_enabled: true }) }); + }); + + it('FE-MOB-SETACC-038: pending codes are restored from sessionStorage', async () => { + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111', 'BBBB-2222'])); + renderAccount(); + + expect(await screen.findByText('Backup codes')).toBeInTheDocument(); + expect(screen.getByText(/AAAA-1111\s+BBBB-2222/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-039: a corrupt payload is dropped from sessionStorage', () => { + sessionStorage.setItem(BACKUP_KEY, '{not json'); + renderAccount(); + + expect(sessionStorage.getItem(BACKUP_KEY)).toBeNull(); + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-040: a payload that is not a list of strings is ignored but kept', () => { + sessionStorage.setItem(BACKUP_KEY, JSON.stringify([1, 2])); + renderAccount(); + + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + expect(sessionStorage.getItem(BACKUP_KEY)).toBe('[1,2]'); + }); + + it('FE-MOB-SETACC-041: an empty list is ignored', () => { + sessionStorage.setItem(BACKUP_KEY, JSON.stringify([])); + renderAccount(); + + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-042: Copy codes writes the joined list to the clipboard', async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, writable: true, configurable: true }); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111', 'BBBB-2222'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Copy codes' })); + + expect(writeText).toHaveBeenCalledWith('AAAA-1111\nBBBB-2222'); + await screen.findByText('Backup codes copied'); + }); + + it('FE-MOB-SETACC-043: a blocked clipboard toasts an error', async () => { + const user = userEvent.setup(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) }, + writable: true, + configurable: true, + }); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Copy codes' })); + + await screen.findByText('Error'); + }); + + it('FE-MOB-SETACC-044: Download TXT builds a blob and revokes the object URL', async () => { + const user = userEvent.setup(); + const createObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:codes'); + const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + let downloadName = ''; + const anchorClick = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function (this: HTMLAnchorElement) { + downloadName = this.download; + }); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Download TXT' })); + + expect(createObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + expect(downloadName).toBe('trek-mfa-backup-codes.txt'); + expect(revokeObjectURL).toHaveBeenCalledWith('blob:codes'); + expect(document.querySelector('a[download]')).toBeNull(); + + createObjectURL.mockRestore(); + revokeObjectURL.mockRestore(); + anchorClick.mockRestore(); + }); + + it('FE-MOB-SETACC-045: Print writes the codes into the popup and triggers print', async () => { + const user = userEvent.setup(); + const written: string[] = []; + const print = vi.fn(); + const popup = { + document: { open: vi.fn(), write: (html: string) => written.push(html), close: vi.fn() }, + focus: vi.fn(), + print, + }; + const openSpy = vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Print / PDF' })); + + expect(openSpy).toHaveBeenCalledWith('', '_blank', 'width=900,height=700'); + expect(written.join('')).toContain('AAAA-1111'); + expect(print).toHaveBeenCalled(); + openSpy.mockRestore(); + }); + + it('FE-MOB-SETACC-046: a blocked popup leaves printing a no-op', async () => { + const user = userEvent.setup(); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Print / PDF' })); + + expect(openSpy).toHaveBeenCalled(); + expect(screen.getByText('Backup codes')).toBeInTheDocument(); + openSpy.mockRestore(); + }); + + it('FE-MOB-SETACC-047: OK dismisses the codes and clears sessionStorage', async () => { + const user = userEvent.setup(); + sessionStorage.setItem(BACKUP_KEY, JSON.stringify(['AAAA-1111'])); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'OK' })); + + expect(screen.queryByText('Backup codes')).not.toBeInTheDocument(); + expect(sessionStorage.getItem(BACKUP_KEY)).toBeNull(); + }); +}); + +// ── Account deletion ───────────────────────────────────────────────────────── + +describe('MSettingsAccount – deletion', () => { + it('FE-MOB-SETACC-048: a regular user gets the confirmation sheet', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Delete your account?')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-049: cancelling closes the sheet again', async () => { + const user = userEvent.setup(); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await screen.findByText('Delete your account?'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByText('Delete your account?')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-050: confirming deletes the account, logs out and returns to the login', async () => { + const user = userEvent.setup(); + const logout = vi.fn(); + seedStore(useAuthStore, { logout }); + let deleted = false; + server.use( + http.delete('/api/auth/me', () => { + deleted = true; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await screen.findByText('Delete your account?'); + await user.click(screen.getByRole('button', { name: 'Delete permanently' })); + + await waitFor(() => expect(deleted).toBe(true)); + expect(logout).toHaveBeenCalled(); + expect(navigateMock).toHaveBeenCalledWith('/login', { state: { noRedirect: true } }); + }); + + it('FE-MOB-SETACC-051: a failing deletion toasts and closes the sheet', async () => { + const user = userEvent.setup(); + server.use(http.delete('/api/auth/me', () => HttpResponse.json({ error: 'Still an owner' }, { status: 409 }))); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await screen.findByText('Delete your account?'); + await user.click(screen.getByRole('button', { name: 'Delete permanently' })); + + await screen.findByText('Still an owner'); + await waitFor(() => expect(screen.queryByText('Delete your account?')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-052: the last remaining admin is blocked', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'root', role: 'admin' }) }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Deletion not possible')).toBeInTheDocument(); + expect(screen.queryByText('Delete your account?')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-053: the blocked sheet closes on OK', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'root', role: 'admin' }) }); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + await screen.findByText('Deletion not possible'); + await user.click(screen.getByRole('button', { name: 'OK' })); + + await waitFor(() => expect(screen.queryByText('Deletion not possible')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-054: an admin with a second admin gets the normal confirmation', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'root', role: 'admin' }) }); + server.use( + http.get('/api/admin/users', () => + HttpResponse.json({ users: [buildUser({ role: 'admin' }), buildUser({ role: 'admin' })] }), + ), + ); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Delete your account?')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-055: a failing admin lookup falls through to the normal confirmation', async () => { + const user = userEvent.setup(); + seedStore(useAuthStore, { user: buildUser({ username: 'root', role: 'admin' }) }); + server.use(http.get('/api/admin/stats', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + renderAccount(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(await screen.findByText('Delete your account?')).toBeInTheDocument(); + }); +}); + +// ── Passkeys ───────────────────────────────────────────────────────────────── + +describe('MSettingsAccount – passkeys', () => { + const onCard = () => appConfig({ passkey_login: true, passkey_configured: true }); + + it('FE-MOB-SETACC-056: the card is not rendered in demo mode', async () => { + seedStore(useAuthStore, { demoMode: true }); + server.use(onCard()); + renderAccount(); + + await waitFor(() => expect(screen.queryByText('Passkeys')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-057: the card disappears when the feature is off and there is no credential', async () => { + renderAccount(); + + await waitFor(() => expect(screen.queryByText('Passkeys')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-058: a failing credential list still settles the card', async () => { + server.use( + http.get('/api/auth/passkey/credentials', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + renderAccount(); + + await waitFor(() => expect(screen.queryByText('Passkeys')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-059: existing credentials keep the card even with the feature off', async () => { + server.use(passkeyList([buildCredential({ name: 'Old key' })])); + renderAccount(); + + expect(await screen.findByText('Old key')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add a passkey' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-060: enabled but unconfigured shows the server hint and no add button', async () => { + server.use(appConfig({ passkey_login: true, passkey_configured: false })); + renderAccount(); + + expect(await screen.findByText(/not fully configured on this server/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add a passkey' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-061: credentials render with their sync state and dates', async () => { + server.use( + onCard(), + passkeyList([ + buildCredential({ id: 1, name: 'iPhone', backed_up: true, last_used_at: '2025-04-05 08:00:00' }), + buildCredential({ id: 2, name: null, backed_up: false, created_at: 'not-a-date', last_used_at: null }), + buildCredential({ id: 3, name: 'Yubikey', created_at: '', last_used_at: null }), + buildCredential({ id: 4, name: 'Tablet', created_at: '2025-05-06T09:00:00Z', last_used_at: null }), + ]), + ); + renderAccount(); + + const first = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + expect(within(first).getByText('Synced')).toBeInTheDocument(); + expect(first.textContent).toContain('Added: 3/4/2025'); + expect(first.textContent).toContain('Last used: 4/5/2025'); + + const second = screen.getByText('Passkey').closest('li') as HTMLElement; + expect(within(second).getByText('This device')).toBeInTheDocument(); + expect(second.textContent).toContain('Added: — · Never used'); + + // Empty timestamp and a full ISO timestamp both go through fmtDate. + expect((screen.getByText('Yubikey').closest('li') as HTMLElement).textContent).toContain('Added: —'); + expect((screen.getByText('Tablet').closest('li') as HTMLElement).textContent).toContain('Added: 5/6/2025'); + }); + + it('FE-MOB-SETACC-061a: renaming an unnamed credential starts from an empty field', async () => { + const user = userEvent.setup(); + server.use(onCard(), passkeyList([buildCredential({ name: null })])); + renderAccount(); + + const row = (await screen.findByText('Passkey')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + + expect((within(row).getByRole('textbox') as HTMLInputElement).value).toBe(''); + }); + + it('FE-MOB-SETACC-062: renaming a credential patches the new name and refreshes', async () => { + const user = userEvent.setup(); + let payload: { name?: string } | null = null; + let renamedId = ''; + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.patch('/api/auth/passkey/credentials/:id', async ({ request, params }) => { + payload = (await request.json()) as { name?: string }; + renamedId = String(params.id); + server.use(passkeyList([buildCredential({ name: 'Work key' })])); + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + const editor = within(row).getByDisplayValue('iPhone'); + await user.clear(editor); + await user.type(editor, 'Work key'); + await user.click(within(row).getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(payload).toEqual({ name: 'Work key' })); + expect(renamedId).toBe('1'); + expect(await screen.findByText('Work key')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-063: Enter commits the rename', async () => { + const user = userEvent.setup(); + let patched = false; + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.patch('/api/auth/passkey/credentials/:id', () => { + patched = true; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + await user.type(within(row).getByDisplayValue('iPhone'), '{Enter}'); + + await waitFor(() => expect(patched).toBe(true)); + }); + + it('FE-MOB-SETACC-064: Escape aborts the rename without a request', async () => { + const user = userEvent.setup(); + let patched = false; + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.patch('/api/auth/passkey/credentials/:id', () => { + patched = true; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + await user.type(within(row).getByDisplayValue('iPhone'), '{Escape}'); + + expect(screen.queryByDisplayValue('iPhone')).not.toBeInTheDocument(); + expect(screen.getByText('iPhone')).toBeInTheDocument(); + expect(patched).toBe(false); + }); + + it('FE-MOB-SETACC-065: the X button leaves the rename editor', async () => { + const user = userEvent.setup(); + server.use(onCard(), passkeyList([buildCredential({ name: 'iPhone' })])); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + await user.click(within(row).getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByDisplayValue('iPhone')).not.toBeInTheDocument(); + expect(screen.getByText('iPhone')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-066: an emptied name closes the editor without a request', async () => { + const user = userEvent.setup(); + let patched = false; + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.patch('/api/auth/passkey/credentials/:id', () => { + patched = true; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + await user.clear(within(row).getByDisplayValue('iPhone')); + await user.click(within(row).getByRole('button', { name: 'Save' })); + + expect(patched).toBe(false); + expect(screen.getByText('iPhone')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-067: a rejected rename shows the server message', async () => { + const user = userEvent.setup(); + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.patch('/api/auth/passkey/credentials/:id', () => + HttpResponse.json({ error: 'Name taken' }, { status: 400 }), + ), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Rename' })); + await user.type(within(row).getByDisplayValue('iPhone'), '2'); + await user.click(within(row).getByRole('button', { name: 'Save' })); + + await screen.findByText('Name taken'); + }); + + it('FE-MOB-SETACC-068: deleting asks for the password and removes the credential', async () => { + const user = userEvent.setup(); + let payload: { password?: string } | null = null; + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.delete('/api/auth/passkey/credentials/:id', async ({ request }) => { + payload = (await request.json()) as { password?: string }; + server.use(passkeyList([])); + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Delete' })); + + const box = screen.getByText(/Remove this passkey/).parentElement as HTMLElement; + expect(within(box).getByRole('button', { name: 'Delete' })).toBeDisabled(); + await user.type(within(box).getByPlaceholderText('Current password'), 'my-password'); + await user.click(within(box).getByRole('button', { name: 'Delete' })); + + await screen.findByText('Passkey removed'); + expect(payload).toEqual({ password: 'my-password' }); + await waitFor(() => expect(screen.queryByText('iPhone')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETACC-069: a rejected delete shows the server message', async () => { + const user = userEvent.setup(); + server.use( + onCard(), + passkeyList([buildCredential({ name: 'iPhone' })]), + http.delete('/api/auth/passkey/credentials/:id', () => + HttpResponse.json({ error: 'Wrong password' }, { status: 403 }), + ), + ); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Delete' })); + + const box = screen.getByText(/Remove this passkey/).parentElement as HTMLElement; + await user.type(within(box).getByPlaceholderText('Current password'), 'nope'); + await user.click(within(box).getByRole('button', { name: 'Delete' })); + + await screen.findByText('Wrong password'); + }); + + it('FE-MOB-SETACC-070: cancelling the delete step-up closes the box', async () => { + const user = userEvent.setup(); + server.use(onCard(), passkeyList([buildCredential({ name: 'iPhone' })])); + renderAccount(); + + const row = (await screen.findByText('iPhone')).closest('li') as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Delete' })); + const box = screen.getByText(/Remove this passkey/).parentElement as HTMLElement; + await user.click(within(box).getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByText(/Remove this passkey/)).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-071: adding runs the WebAuthn ceremony and refreshes the list', async () => { + const user = userEvent.setup(); + webauthn.mockResolvedValue({ id: 'new-credential' }); + let verifyPayload: { name?: string } | null = null; + server.use( + onCard(), + passkeyList([]), + http.post('/api/auth/passkey/register/options', () => HttpResponse.json({ challenge: 'abc' })), + http.post('/api/auth/passkey/register/verify', async ({ request }) => { + verifyPayload = (await request.json()) as { name?: string }; + server.use(passkeyList([buildCredential({ name: 'Work Laptop' })])); + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Add a passkey' })); + const form = screen.getByText('Add a passkey', { selector: 'p' }).parentElement as HTMLElement; + expect(within(form).getByRole('button', { name: 'Add a passkey' })).toBeDisabled(); + await user.type(within(form).getByPlaceholderText('Current password'), 'my-password'); + await user.type(within(form).getByPlaceholderText(/Name \(optional/), 'Work Laptop'); + await user.click(within(form).getByRole('button', { name: 'Add a passkey' })); + + await screen.findByText('Passkey added'); + expect(webauthn).toHaveBeenCalledWith({ optionsJSON: { challenge: 'abc' } }); + expect(verifyPayload).toEqual({ attestationResponse: { id: 'new-credential' }, name: 'Work Laptop' }); + expect(await screen.findByText('Work Laptop')).toBeInTheDocument(); + }); + + it('FE-MOB-SETACC-071a: adding without a name leaves the name out of the payload', async () => { + const user = userEvent.setup(); + webauthn.mockResolvedValue({ id: 'new-credential' }); + let verifyPayload: { name?: string } | null = null; + server.use( + onCard(), + http.post('/api/auth/passkey/register/options', () => HttpResponse.json({ challenge: 'abc' })), + http.post('/api/auth/passkey/register/verify', async ({ request }) => { + verifyPayload = (await request.json()) as { name?: string }; + return HttpResponse.json({ success: true }); + }), + ); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Add a passkey' })); + const form = screen.getByText('Add a passkey', { selector: 'p' }).parentElement as HTMLElement; + await user.type(within(form).getByPlaceholderText('Current password'), 'my-password'); + await user.type(within(form).getByPlaceholderText(/Name \(optional/), ' '); + await user.click(within(form).getByRole('button', { name: 'Add a passkey' })); + + await screen.findByText('Passkey added'); + expect(verifyPayload).toEqual({ attestationResponse: { id: 'new-credential' } }); + }); + + it('FE-MOB-SETACC-072: a cancelled ceremony toasts the cancellation', async () => { + const user = userEvent.setup(); + webauthn.mockRejectedValue(Object.assign(new Error('aborted'), { name: 'NotAllowedError' })); + server.use( + onCard(), + http.post('/api/auth/passkey/register/options', () => HttpResponse.json({ challenge: 'abc' })), + ); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Add a passkey' })); + const form = screen.getByText('Add a passkey', { selector: 'p' }).parentElement as HTMLElement; + await user.type(within(form).getByPlaceholderText('Current password'), 'my-password'); + await user.click(within(form).getByRole('button', { name: 'Add a passkey' })); + + await screen.findByText('Passkey setup cancelled'); + }); + + it('FE-MOB-SETACC-073: a rejected password step-up shows the server message', async () => { + const user = userEvent.setup(); + server.use( + onCard(), + http.post('/api/auth/passkey/register/options', () => + HttpResponse.json({ error: 'Invalid password' }, { status: 401 }), + ), + ); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Add a passkey' })); + const form = screen.getByText('Add a passkey', { selector: 'p' }).parentElement as HTMLElement; + await user.type(within(form).getByPlaceholderText('Current password'), 'nope'); + await user.click(within(form).getByRole('button', { name: 'Add a passkey' })); + + await screen.findByText('Invalid password'); + expect(webauthn).not.toHaveBeenCalled(); + }); + + it('FE-MOB-SETACC-074: cancelling the add form returns to the button', async () => { + const user = userEvent.setup(); + server.use(onCard()); + renderAccount(); + + await user.click(await screen.findByRole('button', { name: 'Add a passkey' })); + await user.type(screen.getByPlaceholderText(/Name \(optional/), 'draft'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(screen.queryByPlaceholderText(/Name \(optional/)).toBeNull(); + expect(screen.getByRole('button', { name: 'Add a passkey' })).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsAppearance.test.tsx b/client/tests/unit/mobile/settings/MSettingsAppearance.test.tsx new file mode 100644 index 000000000..e4570051b --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsAppearance.test.tsx @@ -0,0 +1,400 @@ +// FE-MOB-SETAPP-001 onwards +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import { usePluginStore } from '../../../../src/store/pluginStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import { DEFAULT_APPEARANCE, type AppearanceConfig } from '@trek/shared'; +import type { Settings } from '../../../../src/types'; +import MSettingsAppearance from '../../../../src/mobile/screens/settings/MSettingsAppearance'; + +type UpdateSettingMock = ReturnType; + +function seedAppearance( + appearance: Partial | undefined, + over: Partial = {}, +): UpdateSettingMock { + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { + settings: buildSettings({ + language: 'en', + dark_mode: 'light', + appearance: appearance ? { ...DEFAULT_APPEARANCE, ...appearance } : undefined, + ...over, + }), + updateSetting, + }); + return updateSetting; +} + +function renderAppearance() { + return render( + <> + + + , + ); +} + +/** Persisted appearance blobs, in call order. */ +function appearanceCalls(mock: UpdateSettingMock): AppearanceConfig[] { + return mock.mock.calls.filter((c) => c[0] === 'appearance').map((c) => c[1] as AppearanceConfig); +} + +/** The most recently persisted appearance blob (throws until one exists). */ +function lastAppearance(mock: UpdateSettingMock): AppearanceConfig { + const calls = appearanceCalls(mock); + if (calls.length === 0) throw new Error('no appearance persisted yet'); + return calls[calls.length - 1]; +} + +describe('MSettingsAppearance', () => { + beforeEach(() => { + resetAllStores(); + usePluginStore.setState({ plugins: [], loaded: true }); + seedAppearance(undefined); + }); + + afterEach(() => { + document.documentElement.removeAttribute('data-scheme'); + document.documentElement.removeAttribute('data-density'); + document.documentElement.removeAttribute('data-no-transparency'); + document.documentElement.removeAttribute('data-reduce-motion'); + }); + + it('FE-MOB-SETAPP-001: renders the four cards and the reset action', () => { + renderAppearance(); + + expect(screen.getByText('Theme')).toBeInTheDocument(); + expect(screen.getByText('Mobile', { selector: 'span' })).toBeInTheDocument(); + expect(screen.getByText('Readability')).toBeInTheDocument(); + expect(screen.getByText('Experimental')).toBeInTheDocument(); + expect(screen.getByText('Dashboard widgets')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Reset to defaults/ })).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-002: the color mode segment reflects the stored dark_mode', () => { + seedAppearance(undefined, { dark_mode: 'dark' }); + renderAppearance(); + + expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Light' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('FE-MOB-SETAPP-003: a legacy boolean dark_mode still resolves to the dark segment', () => { + seedAppearance(undefined, { dark_mode: true }); + renderAppearance(); + + expect(screen.getByRole('button', { name: 'Dark' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('FE-MOB-SETAPP-004: picking a color mode persists it right away', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Auto' })); + expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto'); + }); + + it('FE-MOB-SETAPP-005: a failing color-mode save shows the error toast', async () => { + const user = userEvent.setup(); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', dark_mode: 'light' }), + updateSetting: vi.fn().mockRejectedValue(new Error('Server down')), + }); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Dark' })); + await screen.findByText('Server down'); + }); + + it('FE-MOB-SETAPP-006: choosing a scheme applies it to the DOM and persists it debounced', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Teal' })); + + expect(document.documentElement.getAttribute('data-scheme')).toBe('teal'); + await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ schemeId: 'teal' })); + }); + + it('FE-MOB-SETAPP-007: rapid edits collapse into a single persisted blob', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Indigo' })); + await user.click(screen.getByRole('button', { name: 'Rose' })); + await user.click(screen.getByRole('button', { name: 'Violet' })); + + await waitFor(() => expect(appearanceCalls(updateSetting)).toHaveLength(1)); + expect(appearanceCalls(updateSetting)[0].schemeId).toBe('violet'); + }); + + it('FE-MOB-SETAPP-008: a rejected appearance save surfaces the error toast', async () => { + const user = userEvent.setup(); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', dark_mode: 'light' }), + updateSetting: vi.fn().mockRejectedValue(new Error('Blob too large')), + }); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Amber' })); + await screen.findByText('Blob too large'); + }); + + it('FE-MOB-SETAPP-009: the custom scheme reveals the accent presets and the contrast badge', async () => { + const user = userEvent.setup(); + renderAppearance(); + + expect(screen.queryByText('Custom accent')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Custom' })); + + expect(screen.getByText('Custom accent')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '#4f46e5' })).toBeInTheDocument(); + expect(screen.getByText(/Good contrast \(\d\.\d:1\)/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-010: a preset swatch sets both accents and can drop the contrast rating', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } }); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: '#d97706' })); + + expect(screen.getByText(/Low contrast \(\d\.\d:1\)/)).toBeInTheDocument(); + await waitFor(() => + expect(appearanceCalls(updateSetting)[0].accent).toEqual({ light: '#d97706', dark: '#d97706' }), + ); + }); + + it('FE-MOB-SETAPP-011: the two color inputs edit their own mode only', async () => { + const updateSetting = seedAppearance({ schemeId: 'custom', accent: { light: '#4f46e5', dark: '#6366f1' } }); + renderAppearance(); + + const [lightInput, darkInput] = screen.getAllByDisplayValue(/#/) as HTMLInputElement[]; + fireEvent.change(lightInput, { target: { value: '#123456' } }); + expect(darkInput.value).toBe('#6366f1'); + + fireEvent.change(screen.getAllByDisplayValue(/#/)[1], { target: { value: '#abcdef' } }); + await waitFor(() => + expect(lastAppearance(updateSetting).accent).toEqual({ light: '#123456', dark: '#abcdef' }), + ); + }); + + it('FE-MOB-SETAPP-012: a shorthand accent hex is expanded for the contrast rating', () => { + seedAppearance({ schemeId: 'custom', accent: { light: '#abc', dark: '#abc' } }); + renderAppearance(); + + expect(screen.getByText(/contrast \(\d\.\d:1\)/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-013: dark mode rates the dark accent instead of the light one', () => { + seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'dark' }); + renderAppearance(); + + // Near-white on white is unreadable — the dark accent is the one under test. + expect(screen.getByText(/Low contrast/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-014: auto mode follows the OS preference for the accent rating', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList); + seedAppearance({ schemeId: 'custom', accent: { light: '#111111', dark: '#fde68a' } }, { dark_mode: 'auto' }); + renderAppearance(); + + expect(screen.getByText(/Low contrast/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-015: the readability switches write their DOM markers', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getByRole('switch', { name: 'Transparency' })); + expect(document.documentElement.hasAttribute('data-no-transparency')).toBe(true); + + await user.click(screen.getByRole('switch', { name: 'Reduce motion' })); + expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(true); + + await waitFor(() => + expect(lastAppearance(updateSetting)).toMatchObject({ transparency: false, reduceMotion: true }), + ); + }); + + it('FE-MOB-SETAPP-016: switching to compact density sets the density attribute', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Compact' })); + + expect(document.documentElement.getAttribute('data-density')).toBe('compact'); + await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ density: 'compact' })); + }); + + it('FE-MOB-SETAPP-017: the global text slider shows its percentage and persists fontScale', async () => { + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + const sliders = screen.getAllByRole('slider') as HTMLInputElement[]; + expect(sliders).toHaveLength(5); + fireEvent.change(sliders[0], { target: { value: '1.2' } }); + + expect(screen.getByText('120%')).toBeInTheDocument(); + await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toMatchObject({ fontScale: 1.2 })); + }); + + it('FE-MOB-SETAPP-018: each per-tier slider only moves its own scale', async () => { + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + const sliders = screen.getAllByRole('slider') as HTMLInputElement[]; + fireEvent.change(sliders[1], { target: { value: '1.4' } }); + fireEvent.change(sliders[2], { target: { value: '1.3' } }); + fireEvent.change(sliders[3], { target: { value: '0.9' } }); + fireEvent.change(sliders[4], { target: { value: '0.8' } }); + + await waitFor(() => + expect(lastAppearance(updateSetting).typeScale).toEqual({ + title: 1.4, + subtitle: 1.3, + body: 0.9, + caption: 0.8, + }), + ); + }); + + it('FE-MOB-SETAPP-019: desktop and mobile widget toggles are independent', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + const currencySwitches = screen.getAllByRole('switch', { name: 'Currency' }); + expect(currencySwitches).toHaveLength(2); + + await user.click(currencySwitches[0]); + await waitFor(() => expect(lastAppearance(updateSetting).dashboard.desktop.currency).toBe(false)); + expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(true); + + await user.click(screen.getAllByRole('switch', { name: 'Currency' })[1]); + await waitFor(() => expect(lastAppearance(updateSetting).dashboard.mobile.currency).toBe(false)); + }); + + it('FE-MOB-SETAPP-020: turning the sidebar master off disables its child toggles', async () => { + const user = userEvent.setup(); + renderAppearance(); + + const master = screen.getByRole('switch', { name: 'Right sidebar' }); + expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeEnabled(); + + await user.click(master); + expect(screen.getAllByRole('switch', { name: 'Timezones' })[0]).toBeDisabled(); + // The mobile twin stays usable. + expect(screen.getAllByRole('switch', { name: 'Timezones' })[1]).toBeEnabled(); + }); + + it('FE-MOB-SETAPP-021: the desktop-only widgets render outside the sidebar group', () => { + renderAppearance(); + + expect(screen.getByRole('switch', { name: 'Atlas / countries' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'Distance flown' })).toBeInTheDocument(); + expect(screen.getAllByRole('switch', { name: 'Trips total' })).toHaveLength(2); + // "Below the hero" exists once per device, "Bottom of page" only on mobile. + expect(screen.getAllByText('Below the hero')).toHaveLength(2); + expect(screen.getByText('Bottom of page')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-022: the mobile card carries the nav customizer and the dashboard order', () => { + seedStore(useAddonStore, { + addons: [{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true }], + loaded: true, + }); + renderAppearance(); + + expect(screen.getByText('Bottom navbar')).toBeInTheDocument(); + expect(screen.getByText('Pinned')).toBeInTheDocument(); + expect(screen.getByText('Dashboard order')).toBeInTheDocument(); + expect(screen.getByText('Trips')).toBeInTheDocument(); + }); + + it('FE-MOB-SETAPP-023: reordering the mobile dashboard persists the new token order', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getAllByLabelText('Move down')[0]); + await waitFor(() => + expect(lastAppearance(updateSetting).dashboard.mobileOrder).toEqual([ + 'currency', + 'trips', + 'collections', + 'timezones', + 'upcomingReservations', + ]), + ); + }); + + it('FE-MOB-SETAPP-024: editing the bottom navbar persists the new split', async () => { + const user = userEvent.setup(); + seedStore(useAddonStore, { + addons: [ + { id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true }, + { id: 'atlas', name: 'Atlas', type: 'global', icon: 'map', enabled: true }, + ], + loaded: true, + }); + const updateSetting = seedAppearance(undefined); + renderAppearance(); + + await user.click(screen.getAllByLabelText('Move under “More”')[0]); + await waitFor(() => + expect(lastAppearance(updateSetting).mobileNav).toEqual({ bar: ['atlas'], more: ['vacay'] }), + ); + }); + + it('FE-MOB-SETAPP-025: reset restores the defaults and clears the DOM markers', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance({ schemeId: 'rose', density: 'compact', reduceMotion: true, fontScale: 1.3 }); + renderAppearance(); + + expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true'); + + await user.click(screen.getByRole('button', { name: /Reset to defaults/ })); + + expect(document.documentElement.hasAttribute('data-scheme')).toBe(false); + expect(document.documentElement.hasAttribute('data-density')).toBe(false); + expect(document.documentElement.hasAttribute('data-reduce-motion')).toBe(false); + expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true'); + await waitFor(() => expect(appearanceCalls(updateSetting)[0]).toEqual(DEFAULT_APPEARANCE)); + }); + + it('FE-MOB-SETAPP-026: a settings change from elsewhere re-syncs the local config', async () => { + const { rerender } = renderAppearance(); + expect(screen.getByRole('button', { name: 'Comfortable' })).toHaveAttribute('aria-pressed', 'true'); + + seedAppearance({ density: 'compact' }); + rerender(); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'true'), + ); + }); + + it('FE-MOB-SETAPP-027: unmounting before the debounce fires cancels the pending save', async () => { + const user = userEvent.setup(); + const updateSetting = seedAppearance(undefined); + const { unmount } = renderAppearance(); + + await user.click(screen.getByRole('button', { name: 'Indigo' })); + unmount(); + + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(appearanceCalls(updateSetting)).toHaveLength(0); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx b/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx index 0906c1c26..6b6090c39 100644 --- a/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx +++ b/client/tests/unit/mobile/settings/MSettingsGeneral.test.tsx @@ -5,6 +5,7 @@ import { render, screen } from '../../../helpers/render'; import { resetAllStores, seedStore } from '../../../helpers/store'; import { buildSettings } from '../../../helpers/factories'; import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; import MSettingsGeneral from '../../../../src/mobile/screens/settings/MSettingsGeneral'; describe('MSettingsGeneral', () => { @@ -51,4 +52,108 @@ describe('MSettingsGeneral', () => { await user.click(await screen.findByRole('button', { name: 'Deutsch' })); expect(updateSetting).toHaveBeenCalledWith('language', 'de'); }); + + it('FE-MOB-SET-004: a chosen display currency is shown with its symbol', () => { + seedStore(useSettingsStore, { settings: buildSettings({ language: 'en', default_currency: 'EUR' }) }); + render(); + + expect(screen.getByRole('button', { name: 'EUR — €' })).toBeInTheDocument(); + }); + + it('FE-MOB-SET-005: the currency sheet saves a pick and can fall back to the trip currency', async () => { + const user = userEvent.setup(); + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', default_currency: '' }), + updateSetting, + }); + render(); + + await user.click(screen.getByRole('button', { name: 'Trip currency' })); + await user.click(await screen.findByRole('button', { name: 'CHF — CHF' })); + expect(updateSetting).toHaveBeenCalledWith('default_currency', 'CHF'); + }); + + it('FE-MOB-SET-006: distance and time-format segments persist their preference', async () => { + const user = userEvent.setup(); + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', distance_unit: 'metric', time_format: '24h' }), + updateSetting, + }); + render(); + + expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true'); + await user.click(screen.getByRole('button', { name: 'mi Imperial' })); + expect(updateSetting).toHaveBeenCalledWith('distance_unit', 'imperial'); + + await user.click(screen.getByRole('button', { name: '12h' })); + expect(updateSetting).toHaveBeenCalledWith('time_format', '12h'); + }); + + it('FE-MOB-SET-007: unset unit preferences fall back to the metric/24h defaults', () => { + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', temperature_unit: undefined, distance_unit: undefined, time_format: undefined }), + }); + render(); + + expect(screen.getByRole('button', { name: '°C Celsius' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'km Metric' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '24h' })).toHaveAttribute('aria-pressed', 'true'); + }); + + it('FE-MOB-SET-008: the travel toggles reflect their opt-in / opt-out defaults', () => { + render(); + + expect(screen.getByRole('switch', { name: 'Booking route labels' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Explore places on the map' })).toHaveAttribute('aria-checked', 'true'); + expect(screen.getByRole('switch', { name: 'Blur Booking Codes' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'Optimize route from accommodation' })).toHaveAttribute('aria-checked', 'true'); + }); + + it('FE-MOB-SET-009: flipping a travel toggle persists the new boolean', async () => { + const user = userEvent.setup(); + const updateSetting = vi.fn().mockResolvedValue(undefined); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', map_booking_labels: false, map_poi_pill_enabled: true }), + updateSetting, + }); + render(); + + await user.click(screen.getByRole('switch', { name: 'Booking route labels' })); + expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', true); + + await user.click(screen.getByRole('switch', { name: 'Explore places on the map' })); + expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false); + + await user.click(screen.getByRole('switch', { name: 'Blur Booking Codes' })); + expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true); + + await user.click(screen.getByRole('switch', { name: 'Optimize route from accommodation' })); + expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false); + }); + + it('FE-MOB-SET-010: a rejected save shows the server message as a toast', async () => { + const user = userEvent.setup(); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en' }), + updateSetting: vi.fn().mockRejectedValue(new Error('Error saving setting')), + }); + render(<>); + + await user.click(screen.getByRole('button', { name: '°F Fahrenheit' })); + await screen.findByText('Error saving setting'); + }); + + it('FE-MOB-SET-011: a non-Error rejection falls back to the generic message', async () => { + const user = userEvent.setup(); + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en' }), + updateSetting: vi.fn().mockRejectedValue('boom'), + }); + render(<>); + + await user.click(screen.getByRole('button', { name: '°F Fahrenheit' })); + await screen.findByText('Error'); + }); }); diff --git a/client/tests/unit/mobile/settings/MSettingsIntegrations.test.tsx b/client/tests/unit/mobile/settings/MSettingsIntegrations.test.tsx new file mode 100644 index 000000000..f78690c9a --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsIntegrations.test.tsx @@ -0,0 +1,97 @@ +// FE-MOB-SETINT-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { useAddonStore } from '../../../../src/store/addonStore'; +import MSettingsIntegrations from '../../../../src/mobile/screens/settings/MSettingsIntegrations'; + +// The four sections are covered by their own suites — stub them so this file +// only exercises the addon gating and the initial loadAddons call. +vi.mock('../../../../src/mobile/screens/settings/MPhotoProvidersSection', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/settings/MAirTrailConnectionSection', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/settings/MLlmConnectionSection', () => ({ + default: () =>
, +})); +vi.mock('../../../../src/mobile/screens/settings/MSettingsMcp', () => ({ + default: () =>
, +})); + +function seedAddons(ids: string[], loadAddons = vi.fn().mockResolvedValue(undefined)) { + seedStore(useAddonStore, { + addons: ids.map((id) => ({ id, name: id, type: 'integration', icon: '', enabled: true })), + loaded: true, + loadAddons, + }); + return loadAddons; +} + +describe('MSettingsIntegrations', () => { + beforeEach(() => { + resetAllStores(); + }); + + it('FE-MOB-SETINT-001: only the photo providers render when no integration addon is on', () => { + seedAddons([]); + render(); + + expect(screen.getByTestId('photo-providers')).toBeInTheDocument(); + expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument(); + expect(screen.queryByTestId('llm')).not.toBeInTheDocument(); + expect(screen.queryByTestId('mcp')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-002: the airtrail addon reveals the AirTrail connection section', () => { + seedAddons(['airtrail']); + render(); + + expect(screen.getByTestId('airtrail')).toBeInTheDocument(); + expect(screen.queryByTestId('llm')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-003: the llm_parsing addon reveals the AI connection section', () => { + seedAddons(['llm_parsing']); + render(); + + expect(screen.getByTestId('llm')).toBeInTheDocument(); + expect(screen.queryByTestId('airtrail')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-004: the mcp addon reveals the MCP configuration', () => { + seedAddons(['mcp']); + render(); + + expect(screen.getByTestId('mcp')).toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-005: all three sections render together when every addon is on', () => { + seedAddons(['airtrail', 'llm_parsing', 'mcp']); + render(); + + expect(screen.getByTestId('airtrail')).toBeInTheDocument(); + expect(screen.getByTestId('llm')).toBeInTheDocument(); + expect(screen.getByTestId('mcp')).toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-006: a disabled addon row does not unlock its section', () => { + seedStore(useAddonStore, { + addons: [{ id: 'mcp', name: 'MCP', type: 'integration', icon: '', enabled: false }], + loaded: true, + loadAddons: vi.fn().mockResolvedValue(undefined), + }); + render(); + + expect(screen.queryByTestId('mcp')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETINT-007: the addon list is refreshed once on mount', () => { + const loadAddons = seedAddons([]); + const { rerender } = render(); + rerender(); + + expect(loadAddons).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsMap.test.tsx b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx new file mode 100644 index 000000000..ab90c62e3 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsMap.test.tsx @@ -0,0 +1,275 @@ +// FE-MOB-SETMAP-001 onwards +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import type { Settings } from '../../../../src/types'; +import MSettingsMap from '../../../../src/mobile/screens/settings/MSettingsMap'; + +// Leaflet and the GL preview need a real canvas — stub both and expose the +// props the screen feeds them so the preview wiring stays assertable. +vi.mock('../../../../src/components/Map/MapView', () => ({ + MapView: ({ tileUrl }: { tileUrl: string }) =>
, +})); +vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({ + default: ({ provider, token, style, enable3d, quality }: { + provider: string; token: string; style: string; enable3d: boolean; quality: boolean; + }) => ( +
+ ), +})); + +const OSM_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; + +function seedMap(over: Partial = {}, updateSettings = vi.fn().mockResolvedValue(undefined)) { + seedStore(useSettingsStore, { + settings: buildSettings({ language: 'en', map_tile_url: '', ...over }), + updateSettings, + }); + return updateSettings; +} + +function renderMap() { + return render( + <> + + + , + ); +} + +describe('MSettingsMap', () => { + beforeEach(() => { + resetAllStores(); + seedMap(); + }); + + it('FE-MOB-SETMAP-001: offers the three providers with Leaflet active by default', () => { + renderMap(); + + expect(screen.getByText('Leaflet')).toBeInTheDocument(); + expect(screen.getByText('Mapbox GL')).toBeInTheDocument(); + expect(screen.getByText('MapLibre GL')).toBeInTheDocument(); + expect(screen.getByText('Classic 2D, any raster tiles')).toBeInTheDocument(); + expect(screen.getByTestId('leaflet-preview')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-002: the Leaflet branch shows the tile template row and the raw URL input', () => { + seedMap({ map_tile_url: OSM_URL }); + renderMap(); + + expect(screen.getByText('OpenStreetMap')).toBeInTheDocument(); + expect(screen.getByDisplayValue(OSM_URL)).toBeInTheDocument(); + expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', OSM_URL); + }); + + it('FE-MOB-SETMAP-003: an unknown tile URL falls back to the select placeholder', () => { + seedMap({ map_tile_url: 'https://custom.tiles/{z}/{x}/{y}.png' }); + renderMap(); + + expect(screen.getByText('Select template...')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-004: the template sheet writes the chosen preset into the URL field', async () => { + const user = userEvent.setup(); + renderMap(); + + await user.click(screen.getByRole('button', { name: /Select template/ })); + await user.click(await screen.findByRole('button', { name: 'CartoDB Dark' })); + + expect(screen.getByDisplayValue('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-005: typing a tile URL feeds straight into the Leaflet preview', async () => { + const user = userEvent.setup(); + renderMap(); + + await user.type(screen.getByPlaceholderText(OSM_URL), 'https://tiles.test/a.png'); + expect(screen.getByTestId('leaflet-preview')).toHaveAttribute('data-tile', 'https://tiles.test/a.png'); + }); + + it('FE-MOB-SETMAP-006: switching to Mapbox GL swaps in the token, style and 3D controls', async () => { + const user = userEvent.setup(); + renderMap(); + + await user.click(screen.getByText('Mapbox GL')); + + expect(screen.getByPlaceholderText('pk.eyJ1Ijoi...')).toBeInTheDocument(); + expect(screen.getByText('Mapbox Standard')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toBeInTheDocument(); + expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-provider', 'mapbox-gl'); + expect(screen.queryByTestId('leaflet-preview')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-007: MapLibre drops the token field and normalises to an OpenFreeMap style', async () => { + const user = userEvent.setup(); + renderMap(); + + await user.click(screen.getByText('MapLibre GL')); + + expect(screen.queryByPlaceholderText('pk.eyJ1Ijoi...')).not.toBeInTheDocument(); + expect(screen.queryByRole('switch', { name: '3D Buildings & Terrain' })).not.toBeInTheDocument(); + expect(screen.getByText('OpenFreeMap Liberty')).toBeInTheDocument(); + expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty'); + expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-3d', 'false'); + }); + + it('FE-MOB-SETMAP-008: a custom mapbox:// style shows the "pick a style" placeholder', async () => { + const user = userEvent.setup(); + seedMap({ map_provider: 'mapbox-gl', mapbox_style: 'mapbox://styles/me/custom' }); + renderMap(); + + expect(screen.getByText('Select a Mapbox style')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /Select a Mapbox style/ })); + await user.click(await screen.findByRole('button', { name: /Satellite Streets/ })); + expect(screen.getByDisplayValue('mapbox://styles/mapbox/satellite-streets-v12')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-009: an unknown MapLibre style shows the OpenFreeMap placeholder', () => { + seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'https://tiles.openfreemap.org/styles/mine' }); + renderMap(); + + expect(screen.getByText('Select an OpenFreeMap style')).toBeInTheDocument(); + expect(screen.getByText('Preset or OpenFreeMap style URL. OpenFreeMap styles work without a token.')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-010: a stored mapbox provider hydrates token, style and both switches', () => { + seedMap({ + map_provider: 'mapbox-gl', + mapbox_access_token: 'pk.token', + mapbox_style: 'mapbox://styles/mapbox/dark-v11', + mapbox_3d_enabled: false, + mapbox_quality_mode: true, + }); + renderMap(); + + expect(screen.getByDisplayValue('pk.token')).toBeInTheDocument(); + expect(screen.getByRole('switch', { name: '3D Buildings & Terrain' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByRole('switch', { name: 'High Quality Mode' })).toHaveAttribute('aria-checked', 'true'); + const preview = screen.getByTestId('gl-preview'); + expect(preview).toHaveAttribute('data-token', 'pk.token'); + expect(preview).toHaveAttribute('data-3d', 'false'); + expect(preview).toHaveAttribute('data-quality', 'true'); + }); + + it('FE-MOB-SETMAP-011: the 3D and quality switches drive the preview', async () => { + const user = userEvent.setup(); + seedMap({ map_provider: 'mapbox-gl', mapbox_access_token: 'pk.token' }); + renderMap(); + + await user.click(screen.getByRole('switch', { name: '3D Buildings & Terrain' })); + await user.click(screen.getByRole('switch', { name: 'High Quality Mode' })); + + const preview = screen.getByTestId('gl-preview'); + expect(preview).toHaveAttribute('data-3d', 'false'); + expect(preview).toHaveAttribute('data-quality', 'true'); + }); + + it('FE-MOB-SETMAP-012: saving Leaflet persists the tile URL and keeps the mapbox style slot', async () => { + const user = userEvent.setup(); + const updateSettings = seedMap({ map_tile_url: OSM_URL, mapbox_style: 'mapbox://styles/mapbox/dark-v11' }); + renderMap(); + + await user.click(screen.getByRole('button', { name: 'Save Map' })); + + expect(updateSettings).toHaveBeenCalledWith({ + map_provider: 'leaflet', + map_tile_url: OSM_URL, + mapbox_access_token: '', + mapbox_style: 'mapbox://styles/mapbox/dark-v11', + mapbox_3d_enabled: true, + mapbox_quality_mode: false, + }); + await screen.findByText('Map settings saved'); + }); + + it('FE-MOB-SETMAP-013: saving MapLibre writes the maplibre slot, not the mapbox one', async () => { + const user = userEvent.setup(); + const updateSettings = seedMap({ map_provider: 'maplibre-gl' }); + renderMap(); + + await user.click(screen.getByRole('button', { name: 'Save Map' })); + + const saved = updateSettings.mock.calls[0][0] as Partial; + expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty'); + expect(saved).not.toHaveProperty('mapbox_style'); + }); + + it('FE-MOB-SETMAP-014: a non-OpenFreeMap style typed for MapLibre is normalised on save', async () => { + const user = userEvent.setup(); + const updateSettings = seedMap({ map_provider: 'maplibre-gl' }); + renderMap(); + + const styleInput = screen.getByPlaceholderText('https://tiles.openfreemap.org/styles/liberty'); + await user.clear(styleInput); + await user.type(styleInput, 'mapbox://styles/mapbox/streets-v12'); + await user.click(screen.getByRole('button', { name: 'Save Map' })); + + const saved = updateSettings.mock.calls[0][0] as Partial; + expect(saved.maplibre_style).toBe('https://tiles.openfreemap.org/styles/liberty'); + expect(screen.getByDisplayValue('https://tiles.openfreemap.org/styles/liberty')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-015: the save button is disabled while the request is in flight', async () => { + const user = userEvent.setup(); + seedMap({}, vi.fn().mockReturnValue(new Promise(() => {}))); + renderMap(); + + await user.click(screen.getByRole('button', { name: 'Save Map' })); + expect(screen.getByRole('button', { name: 'Save Map' })).toBeDisabled(); + }); + + it('FE-MOB-SETMAP-016: a failed save surfaces the error message', async () => { + const user = userEvent.setup(); + seedMap({}, vi.fn().mockRejectedValue(new Error('Storage full'))); + renderMap(); + + await user.click(screen.getByRole('button', { name: 'Save Map' })); + await screen.findByText('Storage full'); + expect(screen.getByRole('button', { name: 'Save Map' })).toBeEnabled(); + }); + + it('FE-MOB-SETMAP-017: a settings change from elsewhere re-syncs the local form', async () => { + renderMap(); + + seedMap({ map_tile_url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' }); + + await waitFor(() => + expect(screen.getByDisplayValue('https://tile.openstreetmap.de/{z}/{x}/{y}.png')).toBeInTheDocument(), + ); + expect(screen.getByText('OpenStreetMap DE')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMAP-018: a typed Mapbox token reaches the preview and the save payload', async () => { + const user = userEvent.setup(); + const updateSettings = seedMap({ map_provider: 'mapbox-gl' }); + renderMap(); + + await user.type(screen.getByPlaceholderText('pk.eyJ1Ijoi...'), 'pk.new-token'); + expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-token', 'pk.new-token'); + + await user.click(screen.getByRole('button', { name: 'Save Map' })); + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ mapbox_access_token: 'pk.new-token', map_provider: 'mapbox-gl' }), + ); + }); + + it('FE-MOB-SETMAP-019: a mapbox style stored while MapLibre is active is not carried over', () => { + seedMap({ map_provider: 'maplibre-gl', maplibre_style: 'mapbox://styles/mapbox/dark-v11' }); + renderMap(); + + expect(screen.getByTestId('gl-preview')).toHaveAttribute('data-style', 'https://tiles.openfreemap.org/styles/liberty'); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsMcp.test.tsx b/client/tests/unit/mobile/settings/MSettingsMcp.test.tsx new file mode 100644 index 000000000..cabe5ee49 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsMcp.test.tsx @@ -0,0 +1,575 @@ +// FE-MOB-SETMCP-001 onwards +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MSettingsMcp from '../../../../src/mobile/screens/settings/MSettingsMcp'; + +const OAUTH_CLIENT = { + id: 'cl-1', + name: 'Claude Web', + client_id: 'clid-abc', + redirect_uris: ['https://claude.ai/api/mcp/auth_callback'], + allowed_scopes: ['trips:read', 'trips:write', 'places:read', 'places:write', 'budget:read', 'budget:write', 'packing:read'], + allows_client_credentials: false, + created_at: '2025-03-01T10:00:00.000Z', +}; + +const SESSION = { + id: 77, + client_id: 'clid-abc', + client_name: 'Claude Desktop', + scopes: ['trips:read'], + access_token_expires_at: '2025-12-31T00:00:00.000Z', + refresh_token_expires_at: '2026-12-31T00:00:00.000Z', + created_at: '2025-03-01T10:00:00.000Z', +}; + +const TOKEN = { + id: 5, + name: 'Work laptop', + token_prefix: 'trek_ab12', + created_at: '2025-02-01T09:00:00.000Z', + last_used_at: '2025-02-05T09:00:00.000Z', +}; + +function seedApi(opts: { clients?: unknown[]; sessions?: unknown[]; tokens?: unknown[] } = {}) { + server.use( + http.get('/api/oauth/clients', () => HttpResponse.json({ clients: opts.clients ?? [] })), + http.get('/api/oauth/sessions', () => HttpResponse.json({ sessions: opts.sessions ?? [] })), + http.get('/api/auth/mcp-tokens', () => HttpResponse.json({ tokens: opts.tokens ?? [] })), + ); +} + +function renderScreen() { + return render(<>); +} + +/** Switch to the deprecated API-tokens tab. */ +async function openTokenTab(user: ReturnType) { + await user.click(screen.getByRole('tab', { name: 'API Tokens' })); +} + +beforeAll(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + writable: true, + }); +}); + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) }); + seedApi(); +}); + +describe('MSettingsMcp', () => { + it('FE-MOB-SETMCP-001: shows the MCP endpoint and defaults to the OAuth tab', async () => { + renderScreen(); + + expect(screen.getByText('MCP Configuration')).toBeInTheDocument(); + expect(screen.getByText(`${window.location.origin}/mcp`)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'OAuth 2.1 Clients' })).toHaveAttribute('aria-selected', 'true'); + await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument()); + expect(screen.queryByText('No tokens yet. Create one to connect MCP clients.')).toBeNull(); + }); + + it('FE-MOB-SETMCP-002: copying the endpoint writes it to the clipboard and flips the icon', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + renderScreen(); + + const copyBtn = screen.getByRole('button', { name: 'Copy' }); + fireEvent.click(copyBtn); + + expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/mcp`); + await waitFor(() => expect(copyBtn.querySelector('svg')).toHaveClass('text-[color:var(--m-st-confirmed)]')); + }); + + it('FE-MOB-SETMCP-003: the OAuth client config block expands and copies its JSON', async () => { + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + renderScreen(); + + expect(document.querySelector('pre')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' })); + + const pre = document.querySelector('pre'); + expect(pre?.textContent).toContain('mcpServers'); + expect(pre?.textContent).toContain('--static-oauth-client-info'); + + // [0] is the endpoint copy button, [1] the one above the JSON block + fireEvent.click(screen.getAllByRole('button', { name: 'Copy' })[1]); + expect(writeText).toHaveBeenCalledWith(expect.stringContaining('--static-oauth-client-info')); + await waitFor(() => expect(screen.getByRole('button', { name: 'Copied!' })).toBeInTheDocument()); + }); + + it('FE-MOB-SETMCP-004: the API tokens tab shows the deprecation notice and the bearer config', async () => { + const user = userEvent.setup(); + renderScreen(); + await openTokenTab(user); + + expect(screen.getByText(/API Tokens are deprecated/)).toBeInTheDocument(); + await screen.findByText('No tokens yet. Create one to connect MCP clients.'); + expect(screen.queryByText('No OAuth clients registered.')).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Client Configuration' })); + expect(document.querySelector('pre')?.textContent).toContain('Authorization: Bearer '); + }); + + it('FE-MOB-SETMCP-005: an OAuth client renders with its id and a collapsed scope list', async () => { + seedApi({ clients: [OAUTH_CLIENT] }); + renderScreen(); + + await screen.findByText('Claude Web'); + expect(screen.getByText(/clid-abc/)).toBeInTheDocument(); + // 7 scopes, 5 shown plus a "+2" expander + expect(screen.queryByText('packing:read')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '+2' })); + expect(screen.getByText('packing:read')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '−' })); + expect(screen.queryByText('packing:read')).toBeNull(); + }); + + it('FE-MOB-SETMCP-006: a client-credentials client is badged as machine', async () => { + seedApi({ clients: [{ ...OAUTH_CLIENT, allows_client_credentials: true, allowed_scopes: ['trips:read'] }] }); + renderScreen(); + + await screen.findByText('Claude Web'); + expect(screen.getByText('machine')).toBeInTheDocument(); + // Below the 5-scope threshold — no expander + expect(screen.queryByRole('button', { name: /^\+\d+$/ })).toBeNull(); + }); + + it('FE-MOB-SETMCP-007: a preset fills the create form and registers the client with split URIs', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.post('/api/oauth/clients', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ client: { ...OAUTH_CLIENT, id: 'new', client_id: 'clid-new', client_secret: 'sec-new' } }); + }), + ); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await screen.findByText('Register OAuth Client'); + + await user.click(screen.getByRole('button', { name: 'Claude.ai' })); + expect(screen.getByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue('Claude.ai'); + + const uris = screen.getByPlaceholderText(/your-app/); + fireEvent.change(uris, { target: { value: 'https://a.example/cb\n \nhttps://b.example/cb' } }); + await user.click(screen.getByRole('button', { name: 'Register Client' })); + + await screen.findByText('Client Registered'); + expect(body).toMatchObject({ + name: 'Claude.ai', + redirect_uris: ['https://a.example/cb', 'https://b.example/cb'], + }); + // trips:delete is filtered out of the Claude.ai preset + expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).toContain('trips:read'); + expect((body as unknown as { allowed_scopes: string[] }).allowed_scopes).not.toContain('trips:delete'); + + expect(screen.getByText('clid-new')).toBeInTheDocument(); + expect(screen.getByText('sec-new')).toBeInTheDocument(); + // The freshly created client is appended to the list without its secret + expect(screen.getAllByText('Claude Web').length).toBeGreaterThan(0); + }); + + it('FE-MOB-SETMCP-008: the created-client sheet ignores Escape and closes on Done', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/oauth/clients', () => + HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-x', client_secret: 'sec-x' } })), + ); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await screen.findByText('Register OAuth Client'); + // Escape closes while the form is still editable + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull()); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'X'); + fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } }); + await user.click(screen.getByRole('button', { name: 'Register Client' })); + await screen.findByText('Client Registered'); + + await user.keyboard('{Escape}'); + expect(screen.getByText('Client Registered')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => expect(screen.queryByText('Client Registered')).toBeNull()); + }); + + it('FE-MOB-SETMCP-009: the machine toggle drops the redirect URIs and posts client_credentials', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.post('/api/oauth/clients', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ + client: { ...OAUTH_CLIENT, client_id: 'clid-m', client_secret: 'sec-m', allows_client_credentials: true }, + }); + }), + ); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await screen.findByText('Register OAuth Client'); + expect(screen.getByText('Redirect URIs')).toBeInTheDocument(); + + await user.click(screen.getByRole('switch', { name: 'Machine client (no browser login)' })); + expect(screen.queryByText('Redirect URIs')).toBeNull(); + + await user.type(screen.getByPlaceholderText(/Claude Web, My MCP App/), 'CI bot'); + await user.click(screen.getByRole('button', { name: 'Register Client' })); + + await screen.findByText('Client Registered'); + expect(body).toMatchObject({ name: 'CI bot', redirect_uris: [], allows_client_credentials: true }); + expect(screen.getByText(/grant_type=client_credentials/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-010: copies the new client id and secret from the created sheet', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/oauth/clients', () => + HttpResponse.json({ client: { ...OAUTH_CLIENT, client_id: 'clid-copy', client_secret: 'sec-copy' } })), + ); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'C'); + fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } }); + await user.click(screen.getByRole('button', { name: 'Register Client' })); + await screen.findByText('Client Registered'); + + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + const copyButtons = screen.getAllByRole('button', { name: 'Copy' }); + fireEvent.click(copyButtons[copyButtons.length - 2]); + fireEvent.click(copyButtons[copyButtons.length - 1]); + + expect(writeText).toHaveBeenNthCalledWith(1, 'clid-copy'); + expect(writeText).toHaveBeenNthCalledWith(2, 'sec-copy'); + }); + + it('FE-MOB-SETMCP-011: a failed registration surfaces an error toast and keeps the form open', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/oauth/clients', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Broken'); + fireEvent.change(screen.getByPlaceholderText(/your-app/), { target: { value: 'http://localhost' } }); + await user.click(screen.getByRole('button', { name: 'Register Client' })); + + await screen.findByText('Failed to register OAuth client'); + expect(screen.getByText('Register OAuth Client')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-012: deleting a client removes it from the list and toasts', async () => { + const user = userEvent.setup(); + seedApi({ clients: [OAUTH_CLIENT] }); + server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({ success: true }))); + renderScreen(); + + await screen.findByText('Claude Web'); + await user.click(screen.getByRole('button', { name: 'Delete Client' })); + await screen.findByText(/This client and all active sessions will be permanently removed/); + + const confirms = screen.getAllByRole('button', { name: 'Delete Client' }); + await user.click(confirms[confirms.length - 1]); + + await waitFor(() => expect(screen.queryByText('Claude Web')).toBeNull()); + expect(screen.getByText('OAuth client deleted')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-013: a failed delete keeps the client and toasts the error', async () => { + const user = userEvent.setup(); + seedApi({ clients: [OAUTH_CLIENT] }); + server.use(http.delete('/api/oauth/clients/cl-1', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Claude Web'); + await user.click(screen.getByRole('button', { name: 'Delete Client' })); + const confirms = await screen.findAllByRole('button', { name: 'Delete Client' }); + await user.click(confirms[confirms.length - 1]); + + await screen.findByText('Failed to delete OAuth client'); + expect(screen.getByText('Claude Web')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-014: rotating a secret shows the new one and copies it', async () => { + const user = userEvent.setup(); + seedApi({ clients: [OAUTH_CLIENT] }); + server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-1' }))); + renderScreen(); + + await screen.findByText('Claude Web'); + await user.click(screen.getByRole('button', { name: 'Rotate Secret' })); + await screen.findByText(/A new client secret will be generated/); + + await user.click(screen.getByRole('button', { name: 'Rotate' })); + await screen.findByText('New Secret Generated'); + expect(screen.getByText('rotated-1')).toBeInTheDocument(); + + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + const copyButtons = screen.getAllByRole('button', { name: 'Copy' }); + fireEvent.click(copyButtons[copyButtons.length - 1]); + expect(writeText).toHaveBeenCalledWith('rotated-1'); + + await user.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => expect(screen.queryByText('New Secret Generated')).toBeNull()); + }); + + it('FE-MOB-SETMCP-015: a failed rotation toasts and leaves the confirm open', async () => { + const user = userEvent.setup(); + seedApi({ clients: [OAUTH_CLIENT] }); + server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Claude Web'); + await user.click(screen.getByRole('button', { name: 'Rotate Secret' })); + await user.click(await screen.findByRole('button', { name: 'Rotate' })); + + await screen.findByText('Failed to rotate client secret'); + expect(screen.queryByText('New Secret Generated')).toBeNull(); + }); + + it('FE-MOB-SETMCP-016: an active session can be revoked', async () => { + const user = userEvent.setup(); + seedApi({ sessions: [SESSION] }); + server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({ success: true }))); + renderScreen(); + + await screen.findByText('Claude Desktop'); + expect(screen.getByText(/Scopes: trips:read/)).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Revoke' })); + await screen.findByText('This will immediately revoke access for this OAuth session.'); + const revokes = screen.getAllByRole('button', { name: 'Revoke' }); + await user.click(revokes[revokes.length - 1]); + + await waitFor(() => expect(screen.queryByText('Claude Desktop')).toBeNull()); + expect(screen.getByText('Session revoked')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-017: a failed revoke keeps the session and toasts', async () => { + const user = userEvent.setup(); + seedApi({ sessions: [SESSION] }); + server.use(http.delete('/api/oauth/sessions/77', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Claude Desktop'); + await user.click(screen.getByRole('button', { name: 'Revoke' })); + const revokes = await screen.findAllByRole('button', { name: 'Revoke' }); + await user.click(revokes[revokes.length - 1]); + + await screen.findByText('Failed to revoke session'); + expect(screen.getByText('Claude Desktop')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-018: existing API tokens render with their prefix and usage dates', async () => { + const user = userEvent.setup(); + seedApi({ tokens: [TOKEN, { ...TOKEN, id: 6, name: 'Phone', last_used_at: null }] }); + renderScreen(); + await openTokenTab(user); + + expect(await screen.findByText('Work laptop')).toBeInTheDocument(); + expect(screen.getAllByText(/trek_ab12\.\.\./)).toHaveLength(2); + // only the first token was ever used + expect(screen.getByText(/· Used/)).toBeInTheDocument(); + expect(screen.getByText('Phone')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-019: Enter in the token name field creates the token and shows it once', async () => { + const user = userEvent.setup(); + server.use( + http.post('/api/auth/mcp-tokens', () => + HttpResponse.json({ + token: { id: 9, name: 'CLI', token_prefix: 'trek_cli', created_at: '2025-04-01T00:00:00.000Z', raw_token: 'trek_cli_secret' }, + })), + ); + renderScreen(); + await openTokenTab(user); + + await user.click(screen.getByRole('button', { name: 'Create New Token' })); + await screen.findByText('Create API Token'); + expect(screen.getByRole('button', { name: 'Create Token' })).toBeDisabled(); + + await user.type(screen.getByPlaceholderText(/Claude Desktop, Work laptop/), 'CLI'); + await user.keyboard('{Enter}'); + + await screen.findByText('Token Created'); + expect(screen.getByText('trek_cli_secret')).toBeInTheDocument(); + + const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined); + const copyButtons = screen.getAllByRole('button', { name: 'Copy' }); + fireEvent.click(copyButtons[copyButtons.length - 1]); + expect(writeText).toHaveBeenCalledWith('trek_cli_secret'); + + // Escape is refused while the one-time secret is on screen + await user.keyboard('{Escape}'); + expect(screen.getByText('Token Created')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Done' })); + await waitFor(() => expect(screen.queryByText('Token Created')).toBeNull()); + // The new token was prepended to the list + expect(screen.getByText('CLI')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-020: a failed token creation toasts the error', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + await openTokenTab(user); + + await user.click(screen.getByRole('button', { name: 'Create New Token' })); + await user.type(await screen.findByPlaceholderText(/Claude Desktop, Work laptop/), 'Nope'); + await user.click(screen.getByRole('button', { name: 'Create Token' })); + + await screen.findByText('Failed to create token'); + expect(screen.getByText('Create API Token')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-021: cancelling the create-token sheet closes it without a request', async () => { + const user = userEvent.setup(); + let posted = false; + server.use(http.post('/api/auth/mcp-tokens', () => { posted = true; return HttpResponse.json({}); })); + renderScreen(); + await openTokenTab(user); + + await user.click(screen.getByRole('button', { name: 'Create New Token' })); + await screen.findByText('Create API Token'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByText('Create API Token')).toBeNull()); + expect(posted).toBe(false); + }); + + it('FE-MOB-SETMCP-022: deleting a token removes it and toasts', async () => { + const user = userEvent.setup(); + seedApi({ tokens: [TOKEN] }); + server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({ success: true }))); + renderScreen(); + await openTokenTab(user); + + await screen.findByText('Work laptop'); + await user.click(screen.getByRole('button', { name: 'Delete Token' })); + await screen.findByText('This token will stop working immediately. Any MCP client using it will lose access.'); + + const confirms = screen.getAllByRole('button', { name: 'Delete Token' }); + await user.click(confirms[confirms.length - 1]); + + await waitFor(() => expect(screen.queryByText('Work laptop')).toBeNull()); + expect(screen.getByText('Token deleted')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-023: a failed token delete keeps the row and toasts', async () => { + const user = userEvent.setup(); + seedApi({ tokens: [TOKEN] }); + server.use(http.delete('/api/auth/mcp-tokens/5', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + await openTokenTab(user); + + await screen.findByText('Work laptop'); + await user.click(screen.getByRole('button', { name: 'Delete Token' })); + const confirms = await screen.findAllByRole('button', { name: 'Delete Token' }); + await user.click(confirms[confirms.length - 1]); + + await screen.findByText('Failed to delete token'); + expect(screen.getByText('Work laptop')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-024: cancelling the create-client sheet discards the draft', async () => { + const user = userEvent.setup(); + let posted = false; + server.use(http.post('/api/oauth/clients', () => { posted = true; return HttpResponse.json({}); })); + renderScreen(); + + await user.click(screen.getByRole('button', { name: 'New Client' })); + await user.type(await screen.findByPlaceholderText(/Claude Web, My MCP App/), 'Draft'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByText('Register OAuth Client')).toBeNull()); + expect(posted).toBe(false); + + // Re-opening resets the form + await user.click(screen.getByRole('button', { name: 'New Client' })); + expect(await screen.findByPlaceholderText(/Claude Web, My MCP App/)).toHaveValue(''); + }); + + it('FE-MOB-SETMCP-025: cancelling the client confirm sheets performs no request', async () => { + const user = userEvent.setup(); + let calls = 0; + seedApi({ clients: [OAUTH_CLIENT], sessions: [SESSION] }); + server.use( + http.delete('/api/oauth/clients/cl-1', () => { calls += 1; return HttpResponse.json({}); }), + http.post('/api/oauth/clients/cl-1/rotate', () => { calls += 1; return HttpResponse.json({ client_secret: 'x' }); }), + http.delete('/api/oauth/sessions/77', () => { calls += 1; return HttpResponse.json({}); }), + ); + renderScreen(); + await screen.findByText('Claude Web'); + + for (const trigger of ['Delete Client', 'Rotate Secret', 'Revoke']) { + await user.click(screen.getByRole('button', { name: trigger })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()); + } + + expect(calls).toBe(0); + expect(screen.getByText('Claude Web')).toBeInTheDocument(); + expect(screen.getByText('Claude Desktop')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-026: cancelling the delete-token confirm keeps the token', async () => { + const user = userEvent.setup(); + let deleted = false; + seedApi({ tokens: [TOKEN] }); + server.use(http.delete('/api/auth/mcp-tokens/5', () => { deleted = true; return HttpResponse.json({}); })); + renderScreen(); + await openTokenTab(user); + + await screen.findByText('Work laptop'); + await user.click(screen.getByRole('button', { name: 'Delete Token' })); + await user.click(await screen.findByRole('button', { name: 'Cancel' })); + + await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()); + expect(deleted).toBe(false); + expect(screen.getByText('Work laptop')).toBeInTheDocument(); + }); + + it('FE-MOB-SETMCP-027: the rotated-secret sheet dismisses on Escape', async () => { + const user = userEvent.setup(); + seedApi({ clients: [OAUTH_CLIENT] }); + server.use(http.post('/api/oauth/clients/cl-1/rotate', () => HttpResponse.json({ client_secret: 'rotated-esc' }))); + renderScreen(); + + await screen.findByText('Claude Web'); + await user.click(screen.getByRole('button', { name: 'Rotate Secret' })); + await user.click(await screen.findByRole('button', { name: 'Rotate' })); + await screen.findByText('rotated-esc'); + + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByText('rotated-esc')).toBeNull()); + }); + + it('FE-MOB-SETMCP-028: failing list requests leave the empty states in place', async () => { + server.use( + http.get('/api/oauth/clients', () => HttpResponse.json({}, { status: 500 })), + http.get('/api/oauth/sessions', () => HttpResponse.json({}, { status: 500 })), + http.get('/api/auth/mcp-tokens', () => HttpResponse.json({}, { status: 500 })), + ); + renderScreen(); + + await waitFor(() => expect(screen.getByText('No OAuth clients registered.')).toBeInTheDocument()); + expect(screen.queryByText('Active OAuth Sessions')).toBeNull(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx b/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx new file mode 100644 index 000000000..2808470c4 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsNotifications.test.tsx @@ -0,0 +1,559 @@ +// FE-MOB-SETNOTIF-001 onwards +import { describe, it, expect, beforeEach } from 'vitest'; +import { useLocation } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildUser } from '../../../helpers/factories'; +import { server } from '../../../helpers/msw/server'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MSettingsNotifications from '../../../../src/mobile/screens/settings/MSettingsNotifications'; + +const MASKED = '••••••••'; + +interface Channel { + id: string; + source: 'builtin' | 'plugin'; + labelKey?: string; + label?: string; + settingsPath?: string; + active: boolean; + configured: boolean; +} + +const builtin = (id: string, over: Partial = {}): Channel => ({ + id, + source: 'builtin', + labelKey: `settings.notificationPreferences.${id}`, + active: true, + configured: true, + ...over, +}); + +function matrix(over: Record = {}) { + return { + preferences: { trip_invite: { inapp: true, webhook: false } }, + channels: [builtin('inapp'), builtin('webhook')], + event_types: ['trip_invite'], + implemented_combos: { trip_invite: ['inapp', 'webhook'] }, + ...over, + }; +} + +function usePrefs(payload: unknown) { + server.use(http.get('/api/notifications/preferences', () => HttpResponse.json(payload))); +} + +function useSettings(settings: Record) { + server.use(http.get('/api/settings', () => HttpResponse.json({ settings }))); +} + +/** Renders the current router location so navigate() calls are observable. */ +function LocationProbe() { + const loc = useLocation(); + return {`${loc.pathname}${loc.search}`}; +} + +beforeEach(() => { + resetAllStores(); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + usePrefs(matrix()); + useSettings({}); + server.use( + http.put('/api/notifications/preferences', () => HttpResponse.json({ success: true })), + http.put('/api/settings', () => HttpResponse.json({ success: true })), + http.post('/api/settings/bulk', () => HttpResponse.json({ success: true })), + http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: true })), + http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: true })), + ); +}); + +describe('MSettingsNotifications', () => { + it('FE-MOB-SETNOTIF-001: shows the loading line until the matrix arrives', async () => { + usePrefs(new Promise(() => {})); + render(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-002: a failing preferences load keeps the loading line instead of crashing', async () => { + server.use( + http.get('/api/notifications/preferences', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + http.get('/api/settings', () => HttpResponse.json({ error: 'boom' }, { status: 500 })), + ); + render(); + await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); + expect(screen.getByText('Notifications')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-003: reports when no active channel implements any event', async () => { + usePrefs(matrix({ implemented_combos: { trip_invite: [] } })); + render(); + expect(await screen.findByText(/No notification channels are configured/)).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-004: an inactive channel is neither a card nor a chip', async () => { + usePrefs(matrix({ channels: [builtin('inapp'), builtin('webhook', { active: false })] })); + render(); + + await screen.findByText('Trip invitations'); + expect(screen.getByRole('button', { name: 'In-App' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Webhook' })).not.toBeInTheDocument(); + expect(screen.queryByText('Webhook URL')).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-005: the webhook card renders with an unset placeholder and a disabled Test', async () => { + render(); + + expect(await screen.findByText('Webhook URL')).toBeInTheDocument(); + expect(screen.getByText(/Enter your Discord, Slack, or custom webhook URL/)).toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://discord.com/api/webhooks/...')).toHaveValue(''); + expect(screen.getByRole('button', { name: 'Test' })).toBeDisabled(); + }); + + it('FE-MOB-SETNOTIF-006: an already-stored webhook is masked, never echoed into the field', async () => { + useSettings({ webhook_url: MASKED }); + render(); + + const input = await screen.findByPlaceholderText(MASKED); + expect(input).toHaveValue(''); + // A stored URL is enough to test against, even with the field left blank. + expect(screen.getByRole('button', { name: 'Test' })).toBeEnabled(); + }); + + it('FE-MOB-SETNOTIF-007: saving the webhook writes the setting and toasts', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.put('/api/settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + const input = await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'); + await user.type(input, 'https://hooks.example.com/x'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Webhook URL saved')).toBeInTheDocument(); + expect(body).toEqual({ key: 'webhook_url', value: 'https://hooks.example.com/x' }); + }); + + it('FE-MOB-SETNOTIF-008: a rejected webhook save toasts the generic error', async () => { + const user = userEvent.setup(); + server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + render(<>); + + await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'); + const save = screen.getByRole('button', { name: 'Save' }); + await user.click(save); + + expect(await screen.findByText('Error')).toBeInTheDocument(); + await waitFor(() => expect(save).toBeEnabled()); + }); + + it('FE-MOB-SETNOTIF-009: testing the webhook posts the typed URL and toasts on success', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + server.use( + http.post('/api/notifications/test-webhook', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await user.type(await screen.findByPlaceholderText('https://discord.com/api/webhooks/...'), 'https://hooks.example.com/x'); + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(await screen.findByText('Test webhook sent successfully')).toBeInTheDocument(); + expect(body).toEqual({ url: 'https://hooks.example.com/x' }); + }); + + it('FE-MOB-SETNOTIF-010: a webhook test answering success:false shows the server error', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ success: false, error: 'Connection refused' }))); + useSettings({ webhook_url: 'https://hooks.example.com/x' }); + render(<>); + + await screen.findByDisplayValue('https://hooks.example.com/x'); + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(await screen.findByText('Connection refused')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-011: a webhook test request failure falls back to the generic message', async () => { + const user = userEvent.setup(); + server.use(http.post('/api/notifications/test-webhook', () => HttpResponse.json({ error: 'x' }, { status: 500 }))); + useSettings({ webhook_url: 'https://hooks.example.com/x' }); + render(<>); + + await screen.findByDisplayValue('https://hooks.example.com/x'); + const test = screen.getByRole('button', { name: 'Test' }); + await user.click(test); + + expect(await screen.findByText('Test webhook failed')).toBeInTheDocument(); + await waitFor(() => expect(test).toBeEnabled()); + }); + + describe('ntfy channel', () => { + beforeEach(() => { + usePrefs( + matrix({ + preferences: { trip_invite: { inapp: true, ntfy: true } }, + channels: [builtin('inapp'), builtin('ntfy')], + implemented_combos: { trip_invite: ['inapp', 'ntfy'] }, + defaults: { ntfyServer: 'https://ntfy.admin.example' }, + }), + ); + }); + + it('FE-MOB-SETNOTIF-012: hydrates topic and server and offers the admin default as placeholder', async () => { + useSettings({ ntfy_topic: 'my-topic', ntfy_server: '' }); + render(); + + expect(await screen.findByDisplayValue('my-topic')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('https://ntfy.admin.example')).toHaveValue(''); + expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-013: a stored access token is masked and can be cleared', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED }); + server.use( + http.put('/api/settings', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + expect(await screen.findByPlaceholderText(MASKED)).toHaveValue(''); + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(await screen.findByText('Access token cleared')).toBeInTheDocument(); + expect(body).toEqual({ key: 'ntfy_token', value: '' }); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETNOTIF-014: a failing token clear keeps the button and toasts', async () => { + const user = userEvent.setup(); + useSettings({ ntfy_topic: 'my-topic', ntfy_token: MASKED }); + server.use(http.put('/api/settings', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + render(<>); + + await user.click(await screen.findByRole('button', { name: 'Clear' })); + + expect(await screen.findByText('Error')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-015: saving sends topic, server and a freshly typed token in one bulk write', async () => { + const user = userEvent.setup(); + let body: { settings?: Record } | null = null; + useSettings({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh' }); + server.use( + http.post('/api/settings/bulk', async ({ request }) => { + body = (await request.json()) as { settings?: Record }; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await screen.findByDisplayValue('my-topic'); + await user.type(screen.getByPlaceholderText(''), 'tk_abc'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Ntfy settings saved')).toBeInTheDocument(); + expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: 'https://ntfy.sh', ntfy_token: 'tk_abc' }); + // The token is now stored, so the Clear action appears. + expect(screen.getByRole('button', { name: 'Clear' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-016: saving without a token omits it from the payload', async () => { + const user = userEvent.setup(); + let body: { settings?: Record } | null = null; + useSettings({ ntfy_topic: 'my-topic' }); + server.use( + http.post('/api/settings/bulk', async ({ request }) => { + body = (await request.json()) as { settings?: Record }; + return HttpResponse.json({ success: true }); + }), + ); + render(); + + await screen.findByDisplayValue('my-topic'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body?.settings).toEqual({ ntfy_topic: 'my-topic', ntfy_server: '' }); + }); + + it('FE-MOB-SETNOTIF-017: a rejected ntfy save toasts the generic error', async () => { + const user = userEvent.setup(); + useSettings({ ntfy_topic: 'my-topic' }); + server.use(http.post('/api/settings/bulk', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + render(<>); + + await screen.findByDisplayValue('my-topic'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Error')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-018: Test needs a topic and sends the current topic, server and token', async () => { + const user = userEvent.setup(); + let body: Record | null = null; + useSettings({}); + server.use( + http.post('/api/notifications/test-ntfy', async ({ request }) => { + body = (await request.json()) as Record; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + const test = await screen.findByRole('button', { name: 'Test' }); + expect(test).toBeDisabled(); + + await user.type(screen.getByPlaceholderText('my-trek-alerts'), 'alerts'); + await user.click(test); + + expect(await screen.findByText('Test ntfy notification sent successfully')).toBeInTheDocument(); + expect(body).toEqual({ topic: 'alerts', server: null, token: null }); + + // A typed-in server overrides the admin default for the next test run. + await user.type(screen.getByPlaceholderText('https://ntfy.admin.example'), 'https://ntfy.example.org'); + await user.click(test); + await waitFor(() => expect(body).toEqual({ topic: 'alerts', server: 'https://ntfy.example.org', token: null })); + }); + + it('FE-MOB-SETNOTIF-019: an ntfy test answering success:false shows the server error', async () => { + const user = userEvent.setup(); + useSettings({ ntfy_topic: 'alerts' }); + server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ success: false, error: 'Topic unreachable' }))); + render(<>); + + await screen.findByDisplayValue('alerts'); + await user.click(screen.getByRole('button', { name: 'Test' })); + + expect(await screen.findByText('Topic unreachable')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-020: an ntfy test request failure falls back to the generic message', async () => { + const user = userEvent.setup(); + useSettings({ ntfy_topic: 'alerts' }); + server.use(http.post('/api/notifications/test-ntfy', () => HttpResponse.json({ error: 'x' }, { status: 500 }))); + render(<>); + + await screen.findByDisplayValue('alerts'); + const test = screen.getByRole('button', { name: 'Test' }); + await user.click(test); + + expect(await screen.findByText('Test ntfy notification failed')).toBeInTheDocument(); + await waitFor(() => expect(test).toBeEnabled()); + }); + }); + + describe('plugin channels', () => { + const pluginChannel = (over: Partial = {}): Channel => ({ + id: 'plugin:trek-gotify', + source: 'plugin', + label: 'Gotify', + settingsPath: '/settings?tab=plugins', + active: true, + configured: true, + ...over, + }); + + const pluginMatrix = (over: Partial = {}) => + matrix({ + preferences: { trip_invite: { inapp: true, 'plugin:trek-gotify': false } }, + channels: [builtin('inapp'), pluginChannel(over)], + implemented_combos: { trip_invite: ['inapp', 'plugin:trek-gotify'] }, + }); + + it('FE-MOB-SETNOTIF-021: a configured plugin channel offers a test send and no configure link', async () => { + usePrefs(pluginMatrix()); + render(); + + // Once as its own card, once as a chip in the event row. + await waitFor(() => expect(screen.getAllByText('Gotify')).toHaveLength(2)); + expect(screen.getByText(/Configured\. Manage credentials/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send test' })).toBeEnabled(); + expect(screen.queryByRole('button', { name: 'Configure' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-022: an unconfigured plugin channel links to its settings page and blocks the test', async () => { + const user = userEvent.setup(); + usePrefs(pluginMatrix({ configured: false })); + render(<>); + + expect(await screen.findByText(/Not configured yet/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Send test' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Configure' })); + expect(screen.getByTestId('loc')).toHaveTextContent('/settings?tab=plugins'); + }); + + it('FE-MOB-SETNOTIF-023: Send test hits the generic channel route and toasts', async () => { + const user = userEvent.setup(); + let called = ''; + usePrefs(pluginMatrix()); + server.use( + http.post('/api/notifications/test/:channelId', ({ params }) => { + called = String(params.channelId); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + + await user.click(await screen.findByRole('button', { name: 'Send test' })); + + expect(await screen.findByText('Test notification sent.')).toBeInTheDocument(); + expect(called).toBe('plugin:trek-gotify'); + }); + + it('FE-MOB-SETNOTIF-024: a channel test answering success:false surfaces its error', async () => { + const user = userEvent.setup(); + usePrefs(pluginMatrix()); + server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ success: false, error: 'Gotify said no' }))); + render(<>); + + await user.click(await screen.findByRole('button', { name: 'Send test' })); + expect(await screen.findByText('Gotify said no')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-025: a channel test request failure falls back to the generic message', async () => { + const user = userEvent.setup(); + usePrefs(pluginMatrix()); + server.use(http.post('/api/notifications/test/:channelId', () => HttpResponse.json({ error: 'x' }, { status: 500 }))); + render(<>); + + const send = await screen.findByRole('button', { name: 'Send test' }); + await user.click(send); + + expect(await screen.findByText('Test failed.')).toBeInTheDocument(); + await waitFor(() => expect(send).toBeEnabled()); + }); + }); + + describe('event matrix', () => { + it('FE-MOB-SETNOTIF-026: tapping a chip flips the preference and PUTs the whole matrix', async () => { + const user = userEvent.setup(); + let body: Record> | null = null; + server.use( + http.put('/api/notifications/preferences', async ({ request }) => { + body = (await request.json()) as Record>; + return HttpResponse.json({ success: true }); + }), + ); + render(); + + const chip = await screen.findByRole('button', { name: 'In-App' }); + await user.click(chip); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body).toEqual({ trip_invite: { inapp: false, webhook: false } }); + }); + + it('FE-MOB-SETNOTIF-027: a channel with no stored preference defaults to on', async () => { + const user = userEvent.setup(); + let body: Record> | null = null; + usePrefs(matrix({ preferences: {} })); + server.use( + http.put('/api/notifications/preferences', async ({ request }) => { + body = (await request.json()) as Record>; + return HttpResponse.json({ success: true }); + }), + ); + render(); + + await user.click(await screen.findByRole('button', { name: 'Webhook' })); + + await waitFor(() => expect(body).not.toBeNull()); + expect(body).toEqual({ trip_invite: { webhook: false } }); + }); + + it('FE-MOB-SETNOTIF-028: a failing update rolls the matrix back', async () => { + const user = userEvent.setup(); + let reject!: () => void; + server.use( + http.put( + '/api/notifications/preferences', + () => new Promise(resolve => { reject = () => resolve(HttpResponse.json({ error: 'nope' }, { status: 500 }) as unknown as Response); }), + ), + ); + render(); + + const chip = await screen.findByRole('button', { name: 'In-App' }); + // The webhook chip is off in this matrix — its look is the "inactive" reference. + const inactiveClass = screen.getByRole('button', { name: 'Webhook' }).className; + const activeClass = chip.className; + expect(activeClass).not.toBe(inactiveClass); + + await user.click(chip); + // Flipped optimistically while the request is still in flight… + await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(inactiveClass)); + + reject(); + // …and restored once the server rejects it. + await waitFor(() => expect(screen.getByRole('button', { name: 'In-App' }).className).toBe(activeClass)); + }); + + it('FE-MOB-SETNOTIF-029: the saving hint shows while the update is in flight', async () => { + const user = userEvent.setup(); + let release!: () => void; + server.use( + http.put( + '/api/notifications/preferences', + () => new Promise(resolve => { release = () => resolve(HttpResponse.json({ success: true }) as unknown as Response); }), + ), + ); + render(); + + await user.click(await screen.findByRole('button', { name: 'In-App' })); + expect(await screen.findByText('Saving...')).toBeInTheDocument(); + + release(); + await waitFor(() => expect(screen.queryByText('Saving...')).not.toBeInTheDocument()); + }); + + it('FE-MOB-SETNOTIF-030: events without a matching channel are skipped, unknown ones keep their raw key', async () => { + usePrefs( + matrix({ + event_types: ['trip_invite', 'booking_change', 'weird_event'], + implemented_combos: { + trip_invite: ['inapp'], + booking_change: ['email'], + weird_event: ['webhook'], + }, + }), + ); + render(); + + await screen.findByText('Trip invitations'); + // booking_change only implements the inactive email channel → no row at all. + expect(screen.queryByText('Booking changes')).not.toBeInTheDocument(); + // No label key for weird_event → the raw event type is shown. + expect(screen.getByText('weird_event')).toBeInTheDocument(); + }); + + it('FE-MOB-SETNOTIF-031: a plugin channel without a label falls back to its id', async () => { + usePrefs( + matrix({ + preferences: { trip_invite: { 'plugin:x': true } }, + channels: [{ id: 'plugin:x', source: 'plugin', active: true, configured: true }], + implemented_combos: { trip_invite: ['plugin:x'] }, + }), + ); + render(); + + const row = (await screen.findByText('Trip invitations')).parentElement as HTMLElement; + expect(within(row).getByRole('button', { name: 'plugin:x' })).toBeInTheDocument(); + }); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsOffline.test.tsx b/client/tests/unit/mobile/settings/MSettingsOffline.test.tsx new file mode 100644 index 000000000..69bcf4916 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsOffline.test.tsx @@ -0,0 +1,440 @@ +// FE-MOB-OFFLINE-001 onwards +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { act, render, screen, waitFor, within } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildTrip, buildUser } from '../../../helpers/factories'; +import { server } from '../../../helpers/msw/server'; +import { useAuthStore } from '../../../../src/store/authStore'; +import { _resetNetworkMode } from '../../../../src/sync/networkMode'; +import { _resetOfflinePrefs, getOfflinePrefs } from '../../../../src/sync/offlinePrefs'; +import type { QueuedMutation, SyncMeta } from '../../../../src/db/offlineDb'; +import type { PrepareProgress } from '../../../../src/sync/tripSyncManager'; +import type { Trip } from '../../../../src/types'; +import MSettingsOffline from '../../../../src/mobile/screens/settings/MSettingsOffline'; + +// Dexie, the sync manager, the mutation queue and the tile cache are the four +// side-effecting neighbours of this screen; everything else (network mode, +// offline prefs, the trips API) runs for real. +const h = vi.hoisted(() => { + const syncMetaToArray = vi.fn(); + const tripsGet = vi.fn(); + const tripsToArray = vi.fn(); + const placeCount = vi.fn(); + const fileCount = vi.fn(); + const counted = (fn: (id: number) => Promise) => ({ + where: () => ({ equals: (id: number) => ({ count: () => fn(id) }) }), + }); + return { + syncMetaToArray, tripsGet, tripsToArray, placeCount, fileCount, + fakeDb: { + syncMeta: { toArray: syncMetaToArray }, + trips: { get: tripsGet, toArray: tripsToArray }, + places: counted(placeCount), + tripFiles: counted(fileCount), + }, + clearAll: vi.fn(), + clearTripData: vi.fn(), + prepareForOffline: vi.fn(), + syncAll: vi.fn(), + pendingCount: vi.fn(), + failedCount: vi.fn(), + conflicts: vi.fn(), + resolveKeepMine: vi.fn(), + resolveKeepServer: vi.fn(), + clearTileCache: vi.fn(), + }; +}); + +vi.mock('../../../../src/db/offlineDb', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, offlineDb: h.fakeDb, clearAll: h.clearAll, clearTripData: h.clearTripData }; +}); + +vi.mock('../../../../src/sync/tripSyncManager', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, tripSyncManager: { prepareForOffline: h.prepareForOffline, syncAll: h.syncAll } }; +}); + +vi.mock('../../../../src/sync/mutationQueue', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mutationQueue: { + pendingCount: h.pendingCount, + failedCount: h.failedCount, + conflicts: h.conflicts, + resolveKeepMine: h.resolveKeepMine, + resolveKeepServer: h.resolveKeepServer, + }, + }; +}); + +vi.mock('../../../../src/sync/tilePrefetcher', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, clearTileCache: h.clearTileCache }; +}); + +const paris = buildTrip({ id: 1, title: 'Paris', start_date: '2025-06-01', end_date: '2025-06-05' }); +const tokyo = buildTrip({ id: 2, title: 'Tokyo', start_date: '2025-09-01', end_date: '2025-09-15' }); + +const meta = (tripId: number, over: Partial = {}): SyncMeta => ({ + tripId, + lastSyncedAt: null, + status: 'idle', + tilesBbox: null, + filesCachedCount: 0, + ...over, +}); + +const conflict = (over: Partial = {}): QueuedMutation => ({ + id: 'c1', + tripId: 1, + method: 'PUT', + url: '/trips/1/places/9', + body: { name: 'Louvre' }, + createdAt: 0, + status: 'conflict', + attempts: 1, + lastError: null, + ...over, +}); + +/** Seed the Dexie cache with the given (trip, meta) pairs. */ +function cache(rows: { trip: Trip; meta?: Partial; places?: number; files?: number }[]): void { + h.syncMetaToArray.mockResolvedValue(rows.map(r => meta(r.trip.id, r.meta))); + h.tripsGet.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.trip); + h.placeCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.places ?? 0); + h.fileCount.mockImplementation(async (id: number) => rows.find(r => r.trip.id === id)?.files ?? 0); +} + +function setOnLine(value: boolean): void { + Object.defineProperty(window.navigator, 'onLine', { value, configurable: true }); +} + +const card = (title: string) => screen.getByText(title).closest('section') as HTMLElement; + +beforeEach(() => { + vi.clearAllMocks(); + resetAllStores(); + _resetNetworkMode(); + _resetOfflinePrefs(); + setOnLine(true); + seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }); + + h.syncMetaToArray.mockResolvedValue([]); + h.tripsGet.mockResolvedValue(undefined); + h.tripsToArray.mockResolvedValue([]); + h.placeCount.mockResolvedValue(0); + h.fileCount.mockResolvedValue(0); + h.pendingCount.mockResolvedValue(0); + h.failedCount.mockResolvedValue(0); + h.conflicts.mockResolvedValue([]); + h.clearAll.mockResolvedValue(undefined); + h.clearTripData.mockResolvedValue(undefined); + h.clearTileCache.mockResolvedValue(undefined); + h.prepareForOffline.mockResolvedValue(undefined); + h.syncAll.mockResolvedValue(undefined); + h.resolveKeepMine.mockResolvedValue(undefined); + h.resolveKeepServer.mockResolvedValue(undefined); + + server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [paris, tokyo] }))); +}); + +afterEach(() => { + setOnLine(true); +}); + +describe('MSettingsOffline', () => { + it('FE-MOB-OFFLINE-001: shows the loading line while the cache is being read', async () => { + h.syncMetaToArray.mockReturnValue(new Promise(() => {})); + render(); + + expect(screen.getByText('Loading…')).toBeInTheDocument(); + expect(screen.getByText('Offline mode')).toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-002: an empty cache reports zero stats and disables Clear cache', async () => { + render(); + + expect(await screen.findByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument(); + const stats = card('Offline cache'); + expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('0')).toBeInTheDocument(); + expect(within(stats).getByRole('button', { name: 'Clear cache' })).toBeDisabled(); + expect(screen.queryByText('Failed changes')).not.toBeInTheDocument(); + expect(screen.queryByText('Conflicts')).not.toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-003: cached trips are listed oldest first with their place and file counts', async () => { + cache([ + { trip: tokyo, places: 3, files: 1 }, + { trip: paris, places: 7, files: 2 }, + ]); + render(); + + const cacheCard = card('Offline cache'); + await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); + const titles = within(cacheCard).getAllByText(/^(Paris|Tokyo)$/).map(el => el.textContent); + expect(titles).toEqual(['Paris', 'Tokyo']); + expect(within(cacheCard).getByText(/Jun 1, 2025 – Jun 5, 2025/)).toHaveTextContent('7'); + expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('2')).toBeInTheDocument(); + // Never synced → the time column shows a dash. + expect(within(cacheCard).getAllByText('—').length).toBe(2); + }); + + it('FE-MOB-OFFLINE-004: a sync-meta row without its trip in Dexie is dropped', async () => { + h.syncMetaToArray.mockResolvedValue([meta(1), meta(99)]); + h.tripsGet.mockImplementation(async (id: number) => (id === 1 ? paris : undefined)); + render(); + + const cacheCard = card('Offline cache'); + await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); + expect(within(screen.getByText('Cached trips').parentElement as HTMLElement).getByText('1')).toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-005: pending, failed and conflict counters only appear when they are non-zero', async () => { + h.pendingCount.mockResolvedValue(4); + h.failedCount.mockResolvedValue(2); + h.conflicts.mockResolvedValue([conflict()]); + render(); + + await waitFor(() => expect(screen.getByText('Failed changes')).toBeInTheDocument()); + expect(within(screen.getByText('Pending changes').parentElement as HTMLElement).getByText('4')).toBeInTheDocument(); + expect(within(screen.getByText('Failed changes').parentElement as HTMLElement).getByText('2')).toBeInTheDocument(); + expect(within(screen.getByText('Conflicts').parentElement as HTMLElement).getByText('1')).toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-006: conflicts are named from the queued body, the server copy or the entity id', async () => { + h.conflicts.mockResolvedValue([ + conflict({ id: 'a', body: { name: 'Louvre' } }), + conflict({ id: 'b', body: {}, conflictServer: { name: 'Server name' } }), + conflict({ id: 'c', body: {}, entityId: 42 }), + ]); + render(); + + await screen.findByText('Sync conflicts'); + expect(screen.getByText(/Louvre/)).toBeInTheDocument(); + expect(screen.getByText(/Server name/)).toBeInTheDocument(); + expect(screen.getByText(/#42/)).toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-007: keep-mine and keep-theirs resolve the right conflict and reload', async () => { + const user = userEvent.setup(); + h.conflicts.mockResolvedValue([conflict({ id: 'a' }), conflict({ id: 'b', body: { name: 'Eiffel' } })]); + render(); + + const row = (await screen.findByText(/Eiffel/)).parentElement as HTMLElement; + await user.click(within(row).getByRole('button', { name: 'Keep mine' })); + expect(h.resolveKeepMine).toHaveBeenCalledWith('b'); + + await user.click(within(row).getByRole('button', { name: 'Keep theirs' })); + expect(h.resolveKeepServer).toHaveBeenCalledWith('b'); + // Both resolutions re-read the cache (once on mount + once per resolution). + await waitFor(() => expect(h.conflicts).toHaveBeenCalledTimes(3)); + }); + + it('FE-MOB-OFFLINE-008: the conflict strategy segments persist the choice', async () => { + const user = userEvent.setup(); + h.conflicts.mockResolvedValue([conflict()]); + render(); + + await screen.findByText('When a conflict happens'); + expect(screen.getByRole('button', { name: 'Ask me each time' })).toHaveAttribute('aria-pressed', 'true'); + + await user.click(screen.getByRole('button', { name: 'Always keep my version' })); + expect(getOfflinePrefs().conflictStrategy).toBe('mine'); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Always keep my version' })).toHaveAttribute('aria-pressed', 'true'), + ); + }); + + it('FE-MOB-OFFLINE-009: Prepare renders live progress and the done marker', async () => { + const user = userEvent.setup(); + let release!: () => void; + h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { + cb({ phase: 'files', current: 1, total: 4, label: 'Paris' }); + await new Promise(resolve => { release = resolve; }); + cb({ phase: 'done', current: 4, total: 4 }); + }); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('button', { name: 'Download for offline use' })); + + expect(await screen.findByText('Documents · 1/4 · Paris')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Downloading…' })).toBeDisabled(); + + await act(async () => { release(); }); + expect(await screen.findByText('Ready for offline use')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeEnabled(); + }); + + it('FE-MOB-OFFLINE-010: a progress report without a total renders a full bar', async () => { + const user = userEvent.setup(); + let release!: () => void; + h.prepareForOffline.mockImplementation(async (cb: (p: PrepareProgress) => void) => { + cb({ phase: 'tiles', current: 0, total: 0 }); + await new Promise(resolve => { release = resolve; }); + }); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('button', { name: 'Download for offline use' })); + + const label = await screen.findByText('Map tiles · 0/0'); + const bar = label.previousElementSibling?.firstElementChild as HTMLElement; + expect(bar).toHaveStyle({ width: '100%' }); + await act(async () => { release(); }); + }); + + it('FE-MOB-OFFLINE-011: forcing offline downloads first, then engages and locks the actions', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('switch', { name: 'Force offline mode' })); + + expect(h.prepareForOffline).toHaveBeenCalledTimes(1); + expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeDisabled(); + + await user.click(screen.getByRole('switch', { name: 'Force offline mode' })); + await waitFor(() => expect(screen.queryByText(/Offline mode is on/)).not.toBeInTheDocument()); + expect(screen.getByRole('button', { name: 'Re-sync now' })).toBeEnabled(); + }); + + it('FE-MOB-OFFLINE-012: forcing offline while already disconnected skips the download', async () => { + const user = userEvent.setup(); + setOnLine(false); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + // Already offline: prepare/resync are dead, and flipping the switch cannot download. + expect(screen.getByRole('button', { name: 'Download for offline use' })).toBeDisabled(); + await user.click(screen.getByRole('switch', { name: 'Force offline mode' })); + + expect(h.prepareForOffline).not.toHaveBeenCalled(); + expect(await screen.findByText(/Offline mode is on/)).toBeInTheDocument(); + // Offline, the trip list comes out of Dexie instead of the API. + expect(h.tripsToArray).toHaveBeenCalled(); + }); + + it('FE-MOB-OFFLINE-013: Re-sync runs syncAll and re-reads the cache', async () => { + const user = userEvent.setup(); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + await user.click(screen.getByRole('button', { name: 'Re-sync now' })); + + expect(h.syncAll).toHaveBeenCalledTimes(1); + await waitFor(() => expect(h.syncMetaToArray).toHaveBeenCalledTimes(2)); + }); + + it('FE-MOB-OFFLINE-014: turning map tiles off drops the tile cache, turning them back on does not', async () => { + const user = userEvent.setup(); + render(); + + const toggle = await screen.findByRole('switch', { name: 'Store map tiles offline' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + + await user.click(toggle); + expect(getOfflinePrefs().cacheTiles).toBe(false); + expect(h.clearTileCache).toHaveBeenCalledTimes(1); + await waitFor(() => expect(toggle).toHaveAttribute('aria-checked', 'false')); + + await user.click(toggle); + expect(getOfflinePrefs().cacheTiles).toBe(true); + expect(h.clearTileCache).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-OFFLINE-015: switching a trip off evicts it, switching it back on re-syncs', async () => { + const user = userEvent.setup(); + render(); + + const toggle = await screen.findByRole('switch', { name: 'Tokyo' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + expect(screen.getAllByText('Stored offline')).toHaveLength(2); + + await user.click(toggle); + expect(h.clearTripData).toHaveBeenCalledWith(2); + await waitFor(() => expect(screen.getByText('Not stored')).toBeInTheDocument()); + + await user.click(toggle); + await waitFor(() => expect(h.syncAll).toHaveBeenCalledTimes(1)); + expect(h.clearTripData).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-OFFLINE-016: the per-trip section disappears when there are no trips at all', async () => { + server.use(http.get('/api/trips', () => HttpResponse.json({ trips: [] }))); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + expect(screen.queryByText('Trips')).not.toBeInTheDocument(); + expect(screen.getByText('Store map tiles offline')).toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-017: a failing trips API falls back to the Dexie copy', async () => { + server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); + h.tripsToArray.mockResolvedValue([tokyo, paris]); + render(); + + await screen.findByRole('switch', { name: 'Paris' }); + // Dexie rows are sorted by start date too. + const rows = screen.getAllByRole('switch').map(el => el.getAttribute('aria-label')); + expect(rows).toEqual(['Force offline mode', 'Store map tiles offline', 'Paris', 'Tokyo']); + }); + + it('FE-MOB-OFFLINE-018: when both the API and Dexie fail the trip list is simply empty', async () => { + server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); + h.tripsToArray.mockRejectedValue(new Error('dexie is gone')); + render(); + + await screen.findByText('No trips cached yet. Connect to the internet to sync.'); + expect(screen.queryByText('Trips')).not.toBeInTheDocument(); + }); + + it('FE-MOB-OFFLINE-019: Clear cache asks first and can be cancelled', async () => { + const user = userEvent.setup(); + cache([{ trip: paris }]); + render(); + + await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled()); + await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText(/Clear all offline trip data/)).toBeInTheDocument(); + await user.click(within(dialog).getByRole('button', { name: 'Cancel' })); + + expect(h.clearAll).not.toHaveBeenCalled(); + }); + + it('FE-MOB-OFFLINE-020: confirming Clear cache wipes the database and reloads', async () => { + const user = userEvent.setup(); + cache([{ trip: paris }]); + render(); + + await waitFor(() => expect(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })).toBeEnabled()); + await user.click(within(card('Offline cache')).getByRole('button', { name: 'Clear cache' })); + + const dialog = await screen.findByRole('dialog'); + h.syncMetaToArray.mockResolvedValue([]); + await user.click(within(dialog).getByRole('button', { name: 'Clear cache' })); + + expect(h.clearAll).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.getByText('No trips cached yet. Connect to the internet to sync.')).toBeInTheDocument()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('FE-MOB-OFFLINE-021: a synced trip shows its last sync time instead of a dash', async () => { + const at = new Date('2025-06-02T09:30:00Z').getTime(); + cache([{ trip: paris, meta: { lastSyncedAt: at } }]); + render(); + + const cacheCard = card('Offline cache'); + await waitFor(() => expect(within(cacheCard).getByText('Paris')).toBeInTheDocument()); + const expected = new Date(at).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + expect(within(cacheCard).getByText(expected)).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx b/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx new file mode 100644 index 000000000..1aca80e93 --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx @@ -0,0 +1,461 @@ +// FE-MOB-SETPLG-001 onwards +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores, seedStore } from '../../../helpers/store'; +import { buildSettings } from '../../../helpers/factories'; +import { useSettingsStore } from '../../../../src/store/settingsStore'; +import { usePluginStore, type ActivePlugin } from '../../../../src/store/pluginStore'; +import type { PluginAction, PluginUserSettingField } from '../../../../src/api/client'; +import { ToastContainer } from '../../../../src/components/shared/Toast'; +import MSettingsPlugins from '../../../../src/mobile/screens/settings/MSettingsPlugins'; + +// The plugin's own settings.html lives behind a sandboxed iframe + postMessage +// bridge — out of scope here, so the frame is stubbed to its identifying props. +vi.mock('../../../../src/components/Plugins/PluginFrame', () => ({ + default: ({ pluginId, path, title }: { pluginId: string; path?: string; title?: string }) => ( +
+ ), +})); + +const SECRET_MASK = '••••••••'; + +const PLUGIN: ActivePlugin = { id: 'p1', name: 'Weather Pro', type: 'integration', icon: 'Cloud' }; + +const FIELDS: PluginUserSettingField[] = [ + { key: 'api_key', label: 'API key', input_type: 'text', secret: true, required: true, placeholder: 'sk-key', hint: 'From the provider dashboard' }, + { key: 'endpoint', label: 'Endpoint', input_type: 'text', placeholder: 'https://api.example' }, + { key: 'retries', label: 'Retries', input_type: 'number', placeholder: '3' }, + { key: 'notify', label: 'Notify me', input_type: 'checkbox', hint: 'Send a push' }, + { key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }] }, + { key: 'raw' }, +]; + +const CONFIG: Record = { + api_key: SECRET_MASK, + endpoint: 'https://api.example', + retries: 3, + notify: true, + mode: null, + raw: null, +}; + +const ACTIONS: PluginAction[] = [ + { key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false }, + { key: 'reset', label: 'Reset cache', danger: true }, +]; + +const ACTIVITY = [ + { ts: '2025-05-01T10:00:00.000Z', plugin_id: 'p1', plugin_name: 'Weather Pro', method: 'GET', resource: '/trips/1', code: 'ok' }, + { ts: 'not-a-date', plugin_id: 'p2', plugin_name: null, method: 'POST', resource: null, code: 'FORBIDDEN' }, + { ts: '2025-05-02T10:00:00.000Z', plugin_id: 'p3', plugin_name: 'Sync', method: 'PUT', resource: '/atlas', code: 'RATE_LIMIT' }, +]; + +interface SettingsRoute { + fields?: PluginUserSettingField[] + config?: Record + actions?: PluginAction[] + oauth?: { configured: boolean; connected: boolean } +} + +function seedPluginApi(id: string, r: SettingsRoute) { + server.use( + http.get(`/api/plugin-settings/${id}`, () => + HttpResponse.json({ fields: r.fields ?? [], config: r.config ?? {}, actions: r.actions ?? [] })), + http.get(`/api/plugin-oauth/${id}/status`, () => + HttpResponse.json(r.oauth ?? { configured: false, connected: false })), + ); +} + +function renderScreen() { + return render(<>); +} + +beforeEach(() => { + resetAllStores(); + seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) }); + usePluginStore.setState({ plugins: [], loaded: true }); + server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: [] }))); +}); + +describe('MSettingsPlugins', () => { + it('FE-MOB-SETPLG-001: without active plugins only the empty note and the activity card render', async () => { + renderScreen(); + + expect(screen.getByText('No plugins are active.')).toBeInTheDocument(); + expect(screen.getByText('Plugin activity')).toBeInTheDocument(); + expect(screen.getByText('Loading...')).toBeInTheDocument(); + await screen.findByText('No plugin activity yet.'); + }); + + it('FE-MOB-SETPLG-002: the activity log renders one row per entry with its status tone', async () => { + server.use(http.get('/api/plugin-activity', () => HttpResponse.json({ activity: ACTIVITY }))); + renderScreen(); + + await screen.findByText('Weather Pro'); + // Nameless plugin falls back to its id, missing resource to an em dash + expect(screen.getByText('p2')).toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.getByText('/trips/1')).toBeInTheDocument(); + // An unparsable timestamp is shown verbatim + expect(screen.getByText('not-a-date')).toBeInTheDocument(); + + expect(screen.getByText('ok')).toHaveClass('text-m-muted'); + expect(screen.getByText('FORBIDDEN')).toHaveClass('text-[color:var(--m-st-danger)]'); + expect(screen.getByText('RATE_LIMIT')).toHaveClass('text-[color:var(--m-st-pending)]'); + }); + + it('FE-MOB-SETPLG-003: refresh re-requests the activity log', async () => { + let calls = 0; + server.use(http.get('/api/plugin-activity', () => { calls += 1; return HttpResponse.json({ activity: ACTIVITY }); })); + renderScreen(); + + await screen.findByText('Weather Pro'); + expect(calls).toBe(1); + + await userEvent.setup().click(screen.getByRole('button', { name: 'Refresh' })); + await waitFor(() => expect(calls).toBe(2)); + }); + + it('FE-MOB-SETPLG-004: a failing activity request degrades to the empty state', async () => { + server.use(http.get('/api/plugin-activity', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('No plugin activity yet.'); + }); + + it('FE-MOB-SETPLG-005: declared user fields render as the matching mobile controls', async () => { + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { fields: FIELDS, config: CONFIG }); + renderScreen(); + + await screen.findByText('Weather Pro'); + const secret = screen.getByPlaceholderText('sk-key'); + expect(secret).toHaveAttribute('type', 'password'); + expect(secret).toHaveValue(SECRET_MASK); + expect(screen.getByText('*')).toBeInTheDocument(); + expect(screen.getByText('From the provider dashboard')).toBeInTheDocument(); + + expect(screen.getByPlaceholderText('https://api.example')).toHaveAttribute('type', 'text'); + expect(screen.getByPlaceholderText('3')).toHaveAttribute('type', 'number'); + expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'true'); + // No option matches the null config value + expect(screen.getByRole('button', { name: '—' })).toBeInTheDocument(); + // A field without a label falls back to its key + expect(screen.getByText('raw')).toBeInTheDocument(); + }); + + it('FE-MOB-SETPLG-006: saving posts every field but leaves an untouched secret alone', async () => { + const user = userEvent.setup(); + let body: { config?: Record } | null = null; + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { fields: FIELDS, config: CONFIG }); + server.use( + http.post('/api/plugin-settings/p1', async ({ request }) => { + body = (await request.json()) as { config: Record }; + return HttpResponse.json({ config: { ...CONFIG, endpoint: 'https://new.example' } }); + }), + ); + renderScreen(); + + await screen.findByText('Weather Pro'); + const endpoint = screen.getByPlaceholderText('https://api.example'); + await user.clear(endpoint); + await user.type(endpoint, 'https://new.example'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await screen.findByText('Settings saved'); + expect(body?.config).toEqual({ + endpoint: 'https://new.example', + retries: '3', + notify: true, + mode: '', + raw: '', + }); + expect(body?.config).not.toHaveProperty('api_key'); + expect(endpoint).toHaveValue('https://new.example'); + }); + + it('FE-MOB-SETPLG-007: an edited secret is included in the patch', async () => { + const user = userEvent.setup(); + let body: { config?: Record } | null = null; + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { fields: [FIELDS[0]], config: { api_key: SECRET_MASK } }); + server.use( + http.post('/api/plugin-settings/p1', async ({ request }) => { + body = (await request.json()) as { config: Record }; + return HttpResponse.json({ config: { api_key: SECRET_MASK } }); + }), + ); + renderScreen(); + + const secret = await screen.findByPlaceholderText('sk-key'); + await user.clear(secret); + await user.type(secret, 'sk-live-123'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(body?.config).toEqual({ api_key: 'sk-live-123' })); + // The response re-masks it + expect(secret).toHaveValue(SECRET_MASK); + }); + + it('FE-MOB-SETPLG-008: the checkbox and select controls feed their new values into the patch', async () => { + const user = userEvent.setup(); + let body: { config?: Record } | null = null; + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { fields: [FIELDS[3], FIELDS[4]], config: { notify: true, mode: null } }); + server.use( + http.post('/api/plugin-settings/p1', async ({ request }) => { + body = (await request.json()) as { config: Record }; + return HttpResponse.json({ config: { notify: false, mode: 'slow' } }); + }), + ); + renderScreen(); + + await screen.findByText('Weather Pro'); + await user.click(screen.getByRole('switch', { name: 'Notify me' })); + await user.click(screen.getByRole('button', { name: '—' })); + + // Picker sheet lists the empty option plus the declared ones + await screen.findByRole('button', { name: 'Fast' }); + await user.click(screen.getByRole('button', { name: 'Slow' })); + + await user.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(body?.config).toEqual({ notify: false, mode: 'slow' })); + expect(screen.getByRole('switch', { name: 'Notify me' })).toHaveAttribute('aria-checked', 'false'); + }); + + it('FE-MOB-SETPLG-009: a failed save toasts the error and keeps the edit', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } }); + server.use(http.post('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + const endpoint = await screen.findByPlaceholderText('https://api.example'); + await user.clear(endpoint); + await user.type(endpoint, 'https://broken'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + await screen.findByText('Error'); + expect(endpoint).toHaveValue('https://broken'); + }); + + it('FE-MOB-SETPLG-010: a plugin with no fields, actions or OAuth renders no card', async () => { + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', {}); + renderScreen(); + + await screen.findByText('No plugin activity yet.'); + expect(screen.queryByText('Weather Pro')).toBeNull(); + expect(screen.queryByText('No plugins are active.')).toBeNull(); + }); + + it('FE-MOB-SETPLG-011: a failing settings request is fail-safe — no card, no crash', async () => { + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + server.use( + http.get('/api/plugin-settings/p1', () => HttpResponse.json({}, { status: 500 })), + http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({}, { status: 500 })), + ); + renderScreen(); + + await screen.findByText('No plugin activity yet.'); + expect(screen.queryByText('Weather Pro')).toBeNull(); + }); + + it('FE-MOB-SETPLG-012: a plain action runs straight away and shows its result', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { actions: ACTIONS }); + server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true, message: 'Reached the provider' }))); + renderScreen(); + + await screen.findByText('Actions'); + expect(screen.getByText('Pings the provider')).toBeInTheDocument(); + // Without user fields there is no Save button + expect(screen.queryByRole('button', { name: 'Save' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Test connection' })); + await screen.findByText('Reached the provider'); + }); + + it('FE-MOB-SETPLG-013: an action result without a message falls back to the generic label', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false }] }); + server.use( + http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true })), + http.post('/api/plugin-settings/p1/actions/probe', () => HttpResponse.json({ ok: false })), + ); + renderScreen(); + + await screen.findByText('Actions'); + await user.click(screen.getByRole('button', { name: 'Test connection' })); + // 'common.success' has no English string, so t() echoes the key + await screen.findByText('common.success'); + + await user.click(screen.getByRole('button', { name: 'Probe' })); + await screen.findByText('Error'); + }); + + it('FE-MOB-SETPLG-014: unlabelled fields fall back to their key, hints render below', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + server.use( + // No `actions` key at all — the form must still render. + http.get('/api/plugin-settings/p1', () => HttpResponse.json({ + fields: [ + { key: 'flag', input_type: 'checkbox', hint: 'Toggle it' }, + { key: 'pick', input_type: 'select', hint: 'Choose one', options: [{ value: 'a', label: 'Alpha' }] }, + ], + config: {}, + })), + http.get('/api/plugin-oauth/p1/status', () => HttpResponse.json({ configured: false, connected: false })), + ); + renderScreen(); + + await screen.findByText('Weather Pro'); + expect(screen.getByRole('switch', { name: 'flag' })).toHaveAttribute('aria-checked', 'false'); + expect(screen.getByText('Toggle it')).toBeInTheDocument(); + expect(screen.getByText('Choose one')).toBeInTheDocument(); + expect(screen.queryByText('Actions')).toBeNull(); + + // The picker sheet is titled with the key too + await user.click(screen.getByRole('button', { name: '—' })); + await waitFor(() => expect(screen.getAllByText('pick').length).toBeGreaterThan(1)); + }); + + it('FE-MOB-SETPLG-015: a failing action reports an error result', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { actions: [ACTIONS[0]] }); + server.use(http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Actions'); + await user.click(screen.getByRole('button', { name: 'Test connection' })); + await screen.findByText('Error'); + }); + + it('FE-MOB-SETPLG-016: a danger action asks for confirmation before it runs', async () => { + const user = userEvent.setup(); + let ran = 0; + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { actions: [ACTIONS[1]] }); + server.use(http.post('/api/plugin-settings/p1/actions/reset', () => { ran += 1; return HttpResponse.json({ ok: true, message: 'Cache cleared' }); })); + renderScreen(); + + await screen.findByText('Actions'); + + // Cancelling runs nothing + await user.click(screen.getByRole('button', { name: 'Reset cache' })); + await screen.findByText('Run this action?'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull()); + expect(ran).toBe(0); + + await user.click(screen.getByRole('button', { name: 'Reset cache' })); + await screen.findByText('Run this action?'); + const confirms = screen.getAllByRole('button', { name: 'Reset cache' }); + await user.click(confirms[confirms.length - 1]); + + await screen.findByText('Cache cleared'); + expect(ran).toBe(1); + }); + + it('FE-MOB-SETPLG-017: an unconfigured OAuth plugin shows no connect controls', async () => { + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { actions: [ACTIONS[0]], oauth: { configured: false, connected: false } }); + renderScreen(); + + await screen.findByText('Actions'); + expect(screen.queryByRole('button', { name: 'Connect' })).toBeNull(); + expect(screen.queryByText('Not connected')).toBeNull(); + }); + + it('FE-MOB-SETPLG-018: connecting hands off to the provider authorize URL', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { oauth: { configured: true, connected: false } }); + server.use(http.post('/api/plugin-oauth/p1/connect', () => + HttpResponse.json({ authorizeUrl: `${window.location.origin}/#plugin-authorized` }))); + renderScreen(); + + // The card renders for the OAuth section alone, even without fields + await screen.findByText('Weather Pro'); + expect(screen.getByText('Not connected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Connect' })); + await waitFor(() => expect(window.location.hash).toBe('#plugin-authorized')); + window.location.hash = ''; + }); + + it('FE-MOB-SETPLG-019: a failed connect toasts and re-enables the button', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { oauth: { configured: true, connected: false } }); + server.use(http.post('/api/plugin-oauth/p1/connect', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Not connected'); + await user.click(screen.getByRole('button', { name: 'Connect' })); + + await screen.findByText('Error'); + await waitFor(() => expect(screen.getByRole('button', { name: 'Connect' })).not.toBeDisabled()); + }); + + it('FE-MOB-SETPLG-020: disconnecting flips the section back to "not connected"', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { oauth: { configured: true, connected: true } }); + server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({ connected: false }))); + renderScreen(); + + await screen.findByText('Connected'); + await user.click(screen.getByRole('button', { name: 'Disconnect' })); + + await screen.findByText('Not connected'); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETPLG-021: a failed disconnect keeps the connected state', async () => { + const user = userEvent.setup(); + usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); + seedPluginApi('p1', { oauth: { configured: true, connected: true } }); + server.use(http.post('/api/plugin-oauth/p1/disconnect', () => HttpResponse.json({}, { status: 500 }))); + renderScreen(); + + await screen.findByText('Connected'); + await user.click(screen.getByRole('button', { name: 'Disconnect' })); + + await screen.findByText('Error'); + expect(screen.getByText('Connected')).toBeInTheDocument(); + }); + + it('FE-MOB-SETPLG-022: a settingsUi plugin gets its own framed card', async () => { + usePluginStore.setState({ plugins: [{ ...PLUGIN, settingsUi: true }], loaded: true }); + seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } }); + renderScreen(); + + const frame = await screen.findByTestId('plugin-frame'); + expect(frame).toHaveAttribute('data-plugin', 'p1'); + expect(frame).toHaveAttribute('data-path', 'settings.html'); + expect(frame).toHaveAttribute('data-title', 'Weather Pro'); + // Declared-field form and framed surface are two separate cards + expect(screen.getAllByText('Weather Pro')).toHaveLength(2); + }); + + it('FE-MOB-SETPLG-023: several active plugins each get their own form', async () => { + const second: ActivePlugin = { id: 'p2', name: 'Trip Todos', type: 'widget', icon: null }; + usePluginStore.setState({ plugins: [PLUGIN, second], loaded: true }); + seedPluginApi('p1', { fields: [FIELDS[1]], config: { endpoint: 'https://api.example' } }); + seedPluginApi('p2', { actions: [ACTIONS[0]] }); + renderScreen(); + + await screen.findByText('Weather Pro'); + expect(await screen.findByText('Trip Todos')).toBeInTheDocument(); + expect(screen.queryByText('No plugins are active.')).toBeNull(); + }); +}); diff --git a/client/tests/unit/mobile/settings/MSettingsUi.test.tsx b/client/tests/unit/mobile/settings/MSettingsUi.test.tsx new file mode 100644 index 000000000..d32e37c2b --- /dev/null +++ b/client/tests/unit/mobile/settings/MSettingsUi.test.tsx @@ -0,0 +1,220 @@ +// FE-MOB-SETUI-001 to FE-MOB-SETUI-019 +import { describe, it, expect, vi } from 'vitest'; +import { Globe } from 'lucide-react'; +import { render, screen, fireEvent } from '../../../helpers/render'; +import { + MSetCard, + MSetEyebrow, + MSetSelectRow, + MSetSegments, + MSetOnOff, + MSetRow, + MSetInput, + MSetTextarea, + MSetButton, + MSetHint, +} from '../../../../src/mobile/screens/settings/MSettingsUi'; + +describe('MSetCard', () => { + it('FE-MOB-SETUI-001: renders the title row with a 16px icon and the children below', () => { + const { container } = render( + +

card body

+
, + ); + + expect(screen.getByText('Language & region')).toBeInTheDocument(); + expect(screen.getByText('card body')).toBeInTheDocument(); + const icon = container.querySelector('svg'); + expect(icon).toHaveAttribute('width', '16'); + }); + + it('FE-MOB-SETUI-002: renders the trailing badge slot and keeps the extra class on the section', () => { + const { container } = render( + 3} className="mt-3"> +

body

+
, + ); + + expect(screen.getByText('3')).toBeInTheDocument(); + expect(container.querySelector('section')).toHaveClass('mt-3'); + }); +}); + +describe('MSetEyebrow', () => { + it('FE-MOB-SETUI-003: renders its label and merges the caller class', () => { + const { container } = render(Currency); + + expect(screen.getByText('Currency')).toBeInTheDocument(); + expect(container.firstElementChild).toHaveClass('mb-1'); + }); +}); + +describe('MSetSelectRow', () => { + it('FE-MOB-SETUI-004: shows label plus trailing value and reports the tap', () => { + const onClick = vi.fn(); + render(English} onClick={onClick} />); + + const row = screen.getByRole('button', { name: /Language/ }); + expect(row).toHaveTextContent('English'); + fireEvent.click(row); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SETUI-005: is a plain type=button so it never submits a surrounding form', () => { + render(); + + const row = screen.getByRole('button', { name: 'Timezone' }); + expect(row).toHaveAttribute('type', 'button'); + fireEvent.click(row); + expect(row).toBeInTheDocument(); + }); +}); + +describe('MSetSegments', () => { + const options = [ + { value: 'celsius', label: '°C' }, + { value: 'fahrenheit', label: '°F' }, + ]; + + it('FE-MOB-SETUI-006: marks only the current value as pressed', () => { + render(); + + expect(screen.getByRole('button', { name: '°F' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: '°C' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('FE-MOB-SETUI-007: reports the tapped option value', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: '°C' })); + expect(onChange).toHaveBeenCalledWith('celsius'); + }); +}); + +describe('MSetOnOff', () => { + it('FE-MOB-SETUI-008: exposes both segments under the group label with the current state pressed', () => { + render(); + + const group = screen.getByRole('group', { name: 'Show traffic' }); + expect(group).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'On' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Off' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('FE-MOB-SETUI-009: reports true from the On segment and false from the Off segment', () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'On' })); + expect(onChange).toHaveBeenLastCalledWith(true); + + fireEvent.click(screen.getByRole('button', { name: 'Off' })); + expect(onChange).toHaveBeenLastCalledWith(false); + }); +}); + +describe('MSetRow', () => { + it('FE-MOB-SETUI-010: renders label, sub line and the trailing control', () => { + render(km} />); + + expect(screen.getByText('Distance')).toBeInTheDocument(); + expect(screen.getByText('Kilometres or miles')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'km' })).toBeInTheDocument(); + }); + + it('FE-MOB-SETUI-011: only rows after the first carry the divider', () => { + const { container: first } = render(); + expect(first.firstElementChild).not.toHaveClass('border-t'); + + const { container: second } = render(); + expect(second.firstElementChild).toHaveClass('border-t'); + // Without a sub prop the row stays a single line of text. + expect(second.textContent).toBe('Second'); + }); +}); + +describe('MSetInput / MSetTextarea', () => { + it('FE-MOB-SETUI-012: forwards type, placeholder and change events', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByPlaceholderText('Email') as HTMLInputElement; + expect(input.type).toBe('email'); + expect(input.value).toBe('a@b.c'); + + fireEvent.change(input, { target: { value: 'x@y.z' } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SETUI-013: mono switches the face and the mono flag never leaks into the DOM', () => { + render(); + + const input = screen.getByLabelText('Secret'); + expect(input).toHaveClass('font-mono', 'mt-2'); + expect(input).not.toHaveAttribute('mono'); + }); + + it('FE-MOB-SETUI-014: the textarea forwards rows, value and change events', () => { + const onChange = vi.fn(); + render(); + + const area = screen.getByLabelText('Notes') as HTMLTextAreaElement; + expect(area.rows).toBe(4); + expect(area.value).toBe('notes'); + expect(area).toHaveClass('mt-2'); + + fireEvent.change(area, { target: { value: 'more' } }); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); + +describe('MSetButton', () => { + it('FE-MOB-SETUI-015: the primary variant is the accent-filled pill', () => { + render(Save); + + const button = screen.getByRole('button', { name: 'Save' }); + expect(button).toHaveClass('bg-m-act', 'text-m-actfg'); + expect(button).toHaveAttribute('type', 'button'); + }); + + it('FE-MOB-SETUI-016: the danger variant uses the danger ink, ghost stays neutral', () => { + render( + <> + Delete + Cancel + , + ); + + expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass('text-[color:var(--m-st-danger)]'); + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveClass('text-m-ink'); + }); + + it('FE-MOB-SETUI-017: a click reaches onClick', () => { + const onClick = vi.fn(); + render(Update); + + fireEvent.click(screen.getByRole('button', { name: 'Update' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-SETUI-018: a disabled button swallows the click', () => { + const onClick = vi.fn(); + render(Update); + + const button = screen.getByRole('button', { name: 'Update' }); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(onClick).not.toHaveBeenCalled(); + }); +}); + +describe('MSetHint', () => { + it('FE-MOB-SETUI-019: renders the hint text with the caller class', () => { + const { container } = render(Stored encrypted.); + + expect(screen.getByText('Stored encrypted.')).toBeInTheDocument(); + expect(container.querySelector('p')).toHaveClass('mb-2'); + }); +}); diff --git a/client/tests/unit/mobile/vacay/MVacay.test.tsx b/client/tests/unit/mobile/vacay/MVacay.test.tsx new file mode 100644 index 000000000..d8fff7ef4 --- /dev/null +++ b/client/tests/unit/mobile/vacay/MVacay.test.tsx @@ -0,0 +1,370 @@ +// FE-MOB-MVACSCR-001 to FE-MOB-MVACSCR-016 +import React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, within } from '../../../helpers/render'; +import MVacay from '../../../../src/mobile/screens/vacay/MVacay'; +import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel'; + +const mocks = vi.hoisted(() => ({ v: {} as Record })); + +vi.mock('../../../../src/mobile/screens/vacay/useMVacay', () => ({ + useMVacay: () => mocks.v, +})); + +interface SheetStubProps { open: boolean; onClose: () => void } + +vi.mock('../../../../src/mobile/screens/vacay/MVacayInviteSheet', () => ({ + default: ({ open, onClose }: SheetStubProps) => ( +
+ +
+ ), +})); +vi.mock('../../../../src/mobile/screens/vacay/MVacaySettingsSheet', () => ({ + default: ({ open, onClose }: SheetStubProps) => ( +
+ +
+ ), +})); +vi.mock('../../../../src/mobile/screens/vacay/MVacayShareSheet', () => ({ + default: ({ open, onClose }: SheetStubProps) => ( +
+ +
+ ), +})); + +const dayCtx: DayVisualContext = { + todayStr: '2099-01-01', + entryMap: {}, + companyHolidaySet: new Set(), + companyHolidaysEnabled: true, + holidays: {}, + weekendDays: [0, 6], +}; + +const MONTHS_SHORT = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + +/** The screen flips the modifier flags with an updater, so the mock has to run it. */ +function boolSetter() { + return vi.fn((update: boolean | ((prev: boolean) => boolean)) => { + if (typeof update === 'function') update(false); + }); +} + +function buildV(over: Record = {}): Record { + return { + loading: false, + plan: { id: 1, holiday_calendars: [] }, + selectedYear: 2026, + users: [ + { id: 1, username: 'alice', color: '#3b82f6' }, + { id: 2, username: 'bob', color: null }, + ], + isFused: false, + currentUser: { id: 1, username: 'alice' }, + incomingInvites: [], + acceptInvite: vi.fn((_planId: number) => undefined), + declineInvite: vi.fn((_planId: number) => undefined), + incomingShares: [], + toggleShareHidden: vi.fn((_id: number, _hidden: boolean) => undefined), + view: 'grid', + months: Array.from({ length: 12 }, (_, m) => ({ year: 2026, month: m })), + monthSlot: 5, + activeMonth: { year: 2026, month: 5 }, + isShiftedYear: false, + mode: 'vacation', + halfDay: false, + setHalfDay: boolSetter(), + compDay: false, + setCompDay: boolSetter(), + sheet: null, + setSheet: vi.fn(() => undefined), + setMode: vi.fn(() => undefined), + setMonthSlot: vi.fn((_slot: number) => undefined), + tripDates: new Set(), + tripDotColor: '#3b82f6', + blockWeekends: true, + companyHolidaysEnabled: true, + holidaysEnabled: false, + weekStart: 1, + weekendDays: [0, 6], + dayCtx, + monthNamesShort: MONTHS_SHORT, + monthNameLong: 'June', + selectedUser: { id: 1, username: 'alice', color: '#3b82f6' }, + selectedColor: '#3b82f6', + selectedStat: { user_id: 1, vacation_days: 30, used: 4, total_available: 30, remaining: 26, carried_over: 0 }, + selectedUserId: 1, + selectPerson: vi.fn((_id: number) => undefined), + handleDayTap: vi.fn(async (_date: string) => {}), + allowInc: vi.fn(() => undefined), + allowDec: vi.fn(() => undefined), + prevYear: vi.fn(async () => {}), + nextYear: vi.fn(async () => {}), + prevMonth: vi.fn(() => undefined), + nextMonth: vi.fn(() => undefined), + toggleView: vi.fn(() => undefined), + goBack: vi.fn(() => undefined), + ...over, + }; +} + +beforeEach(() => { + mocks.v = buildV(); +}); + +describe('MVacay', () => { + it('FE-MOB-MVACSCR-001: shows only a spinner while the plan is loading', () => { + mocks.v = buildV({ loading: true }); + const { container } = render(); + + expect(container.querySelector('.animate-spin')).not.toBeNull(); + expect(screen.queryByText('2026')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSCR-002: renders the year header and wires its buttons', () => { + render(); + + expect(screen.getByText('2026')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Previous year' })); + fireEvent.click(screen.getByRole('button', { name: 'Next year' })); + expect(mocks.v.prevYear).toHaveBeenCalledTimes(1); + expect(mocks.v.nextYear).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MVACSCR-003: the header icons open the invite, share and settings sheets', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Invite User' })); + fireEvent.click(screen.getByRole('button', { name: 'Shared Calendars' })); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + + expect(mocks.v.setSheet).toHaveBeenNthCalledWith(1, 'invite'); + expect(mocks.v.setSheet).toHaveBeenNthCalledWith(2, 'share'); + expect(mocks.v.setSheet).toHaveBeenNthCalledWith(3, 'settings'); + }); + + it('FE-MOB-MVACSCR-004: passes the open sheet down to the matching child only', () => { + mocks.v = buildV({ sheet: 'settings' }); + render(); + + expect(screen.getByTestId('settings-sheet')).toHaveAttribute('data-open', 'true'); + expect(screen.getByTestId('invite-sheet')).toHaveAttribute('data-open', 'false'); + expect(screen.getByTestId('share-sheet')).toHaveAttribute('data-open', 'false'); + }); + + it('FE-MOB-MVACSCR-004b: each sheet closes back to no open sheet', () => { + mocks.v = buildV({ sheet: 'invite' }); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'close-invite' })); + fireEvent.click(screen.getByRole('button', { name: 'close-settings' })); + fireEvent.click(screen.getByRole('button', { name: 'close-share' })); + expect(mocks.v.setSheet).toHaveBeenCalledTimes(3); + expect(mocks.v.setSheet).toHaveBeenNthCalledWith(3, null); + }); + + it('FE-MOB-MVACSCR-005: renders the person card with the remaining balance and stepper', () => { + render(); + + expect(screen.getByText('alice', { selector: 'div' })).toBeInTheDocument(); + const badge = screen.getByText('left'); + expect(badge).toHaveTextContent('26'); + expect(badge.style.color).toBe('rgb(59, 130, 246)'); + expect(screen.getByText('Days').previousElementSibling).toHaveTextContent('30'); + + fireEvent.click(screen.getByRole('button', { name: 'Increase allowance' })); + fireEvent.click(screen.getByRole('button', { name: 'Decrease allowance' })); + expect(mocks.v.allowInc).toHaveBeenCalledTimes(1); + expect(mocks.v.allowDec).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MVACSCR-006: renders a fractional balance with one decimal', () => { + mocks.v = buildV({ + selectedStat: { user_id: 1, vacation_days: 30, used: 4.5, total_available: 30, remaining: 25.5, carried_over: 0 }, + }); + render(); + + expect(screen.getByText('25.5')).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSCR-007: colors an overdrawn balance as a danger badge', () => { + mocks.v = buildV({ + selectedStat: { user_id: 1, vacation_days: 30, used: 32, total_available: 30, remaining: -2, carried_over: 0 }, + }); + render(); + + const badge = screen.getByText('-2').parentElement as HTMLElement; + expect(badge.style.color).toBe('var(--m-st-danger)'); + }); + + it('FE-MOB-MVACSCR-008: an exhausted balance switches to the pending color', () => { + mocks.v = buildV({ + selectedStat: { user_id: 1, vacation_days: 30, used: 30, total_available: 30, remaining: 0, carried_over: 0 }, + }); + render(); + + const badge = screen.getByText('0').parentElement as HTMLElement; + expect(badge.style.color).toBe('var(--m-st-pending)'); + }); + + it('FE-MOB-MVACSCR-009: hides the person card when no stat row exists', () => { + mocks.v = buildV({ selectedStat: undefined }); + render(); + + expect(screen.queryByRole('button', { name: 'Increase allowance' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSCR-010: person chips select their member and fall back on the shared color', () => { + render(); + + const bobChip = screen.getByRole('button', { name: 'bob' }); + const swatch = bobChip.querySelector('span') as HTMLElement; + expect(swatch.style.background).toBe('rgb(99, 102, 241)'); + + fireEvent.click(bobChip); + expect(mocks.v.selectPerson).toHaveBeenCalledWith(2); + }); + + it('FE-MOB-MVACSCR-011: shared chips toggle their overlay and report their state', () => { + mocks.v = buildV({ + incomingShares: [ + { id: 7, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }, + { id: 8, owner_id: 4, username: 'dan', color: '#10b981', hidden: true }, + ], + }); + render(); + + const carol = screen.getByRole('button', { name: 'carol' }); + const dan = screen.getByRole('button', { name: 'dan' }); + expect(carol).toHaveAttribute('aria-pressed', 'true'); + expect(dan).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(carol); + fireEvent.click(dan); + expect(mocks.v.toggleShareHidden).toHaveBeenNthCalledWith(1, 7, true); + expect(mocks.v.toggleShareHidden).toHaveBeenNthCalledWith(2, 8, false); + }); + + it('FE-MOB-MVACSCR-012: shows the company legend and every enabled holiday calendar', () => { + mocks.v = buildV({ + holidaysEnabled: true, + plan: { + id: 1, + holiday_calendars: [ + { id: 1, region: 'DE', label: 'Germany', color: '#fecaca' }, + { id: 2, region: 'NL', label: null, color: '#bbf7d0' }, + ], + }, + }); + render(); + + expect(screen.getByText('Company days')).toBeInTheDocument(); + expect(screen.getByText('Germany')).toBeInTheDocument(); + expect(screen.getByText('NL')).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSCR-013: the year grid shows twelve month cards and reports day taps', () => { + render(); + + MONTHS_SHORT.forEach(name => expect(screen.getByText(name)).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: '2026-06-15' })); + expect(mocks.v.handleDayTap).toHaveBeenCalledWith('2026-06-15'); + }); + + it('FE-MOB-MVACSCR-014: edit view swaps in the month nav, weekday row and mode switch', () => { + mocks.v = buildV({ view: 'edit' }); + render(); + + expect(screen.getByText('June')).toBeInTheDocument(); + expect(screen.getAllByText('Mon')[0]).toBeInTheDocument(); + // Monday-first week: Sunday is the last weekday header. + const headers = screen.getAllByText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/); + expect(headers[headers.length - 1]).toHaveTextContent('Sun'); + + fireEvent.click(screen.getByRole('button', { name: 'Previous month' })); + fireEvent.click(screen.getByRole('button', { name: 'Next month' })); + expect(mocks.v.prevMonth).toHaveBeenCalledTimes(1); + expect(mocks.v.nextMonth).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('Mar')); + expect(mocks.v.setMonthSlot).toHaveBeenCalledWith(2); + }); + + it('FE-MOB-MVACSCR-015: a Sunday week start moves Sunday to the front of the weekday row', () => { + mocks.v = buildV({ view: 'edit', weekStart: 0 }); + render(); + + const headers = screen.getAllByText(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$/); + expect(headers[0]).toHaveTextContent('Sun'); + }); + + it('FE-MOB-MVACSCR-016: the mode switch drives person, company, comp and half-day logging', () => { + mocks.v = buildV({ view: 'edit' }); + render(); + + // The person chip row carries the same name; the mode switch comes last. + const person = screen.getAllByRole('button', { name: 'alice' }); + fireEvent.click(person[person.length - 1]); + expect(mocks.v.setMode).toHaveBeenLastCalledWith('vacation'); + + fireEvent.click(screen.getByRole('button', { name: 'Company' })); + expect(mocks.v.setMode).toHaveBeenLastCalledWith('company'); + + const comp = screen.getByRole('button', { name: 'Comp / Flex' }); + const half = screen.getByRole('button', { name: 'Half day' }); + expect(comp).toHaveAttribute('aria-pressed', 'false'); + expect(half).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(comp); + fireEvent.click(half); + expect(mocks.v.setCompDay).toHaveBeenCalledTimes(1); + expect(mocks.v.setHalfDay).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MVACSCR-017: the company mode button disappears when company days are off', () => { + mocks.v = buildV({ view: 'edit', companyHolidaysEnabled: false }); + render(); + + expect(screen.queryByRole('button', { name: 'Company' })).not.toBeInTheDocument(); + expect(screen.queryByText('Company days')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSCR-018: the FAB flips between the year overview and the editor', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Edit calendar' })); + expect(mocks.v.toggleView).toHaveBeenCalledTimes(1); + + mocks.v = buildV({ view: 'edit' }); + render(); + expect(screen.getAllByRole('button', { name: 'View year' })).toHaveLength(1); + }); + + it('FE-MOB-MVACSCR-019: an incoming fusion request lists what fusing grants and both actions', () => { + const acceptInvite = vi.fn((_planId: number) => undefined); + const declineInvite = vi.fn((_planId: number) => undefined); + mocks.v = buildV({ + incomingInvites: [{ plan_id: 42, owner_username: 'carol' }], + acceptInvite, + declineInvite, + }); + render(); + + const dialog = screen.getByRole('dialog', { name: 'Fusion Request' }); + expect(within(dialog).getByText('carol')).toBeInTheDocument(); + expect(within(dialog).getByText('C')).toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Accept & Fuse' })); + fireEvent.click(within(dialog).getByRole('button', { name: 'Decline' })); + expect(acceptInvite).toHaveBeenCalledWith(42); + expect(declineInvite).toHaveBeenCalledWith(42); + }); + + it('FE-MOB-MVACSCR-020: no fusion request means no dialog', () => { + render(); + + expect(screen.queryByRole('dialog', { name: 'Fusion Request' })).not.toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/vacay/MVacayInviteSheet.test.tsx b/client/tests/unit/mobile/vacay/MVacayInviteSheet.test.tsx new file mode 100644 index 000000000..f21833318 --- /dev/null +++ b/client/tests/unit/mobile/vacay/MVacayInviteSheet.test.tsx @@ -0,0 +1,147 @@ +// FE-MOB-MVACINV-001 to FE-MOB-MVACINV-010 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useVacayStore } from '../../../../src/store/vacayStore'; +import MVacayInviteSheet from '../../../../src/mobile/screens/vacay/MVacayInviteSheet'; + +const toasts: { type: string; message: string }[] = []; + +function availableUsers(users: { id: number; username: string; color: string | null }[]) { + server.use(http.get('/api/addons/vacay/available-users', () => HttpResponse.json({ users }))); +} + +beforeEach(() => { + resetAllStores(); + toasts.length = 0; + window.__addToast = ((message: string, type?: string) => { + toasts.push({ type: type ?? 'info', message }); + return 1; + }) as Window['__addToast']; + availableUsers([ + { id: 2, username: 'bob', color: '#ec4899' }, + { id: 3, username: 'carol', color: '#f59e0b' }, + ]); +}); + +afterEach(() => { + delete window.__addToast; +}); + +function renderSheet(onClose = vi.fn(() => undefined)) { + const view = render(); + return { ...view, onClose }; +} + +describe('MVacayInviteSheet', () => { + it('FE-MOB-MVACINV-001: renders nothing while closed', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: 'Invite User' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACINV-002: loads the invitable users and shows the picker placeholder', async () => { + renderSheet(); + + expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument(); + expect(screen.getByText('Invite another TREK user to share a combined vacation calendar.')).toBeInTheDocument(); + expect(screen.queryByText('No users available')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACINV-003: falls back to the empty state when the lookup fails', async () => { + server.use(http.get('/api/addons/vacay/available-users', () => HttpResponse.json({ error: 'nope' }, { status: 500 }))); + renderSheet(); + + expect(await screen.findByText('No users available')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Select user' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACINV-004: the picker opens, selects a user and collapses again', async () => { + renderSheet(); + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + + fireEvent.click(screen.getByRole('button', { name: 'carol' })); + expect(await screen.findByRole('button', { name: 'carol' })).toBeInTheDocument(); + // Only the trigger keeps the name once the list collapsed. + expect(screen.getAllByRole('button', { name: 'carol' })).toHaveLength(1); + }); + + it('FE-MOB-MVACINV-005: the send button stays disabled until someone is picked', async () => { + renderSheet(); + const send = await screen.findByRole('button', { name: 'Send Invite' }); + expect(send).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'bob' })); + expect(screen.getByRole('button', { name: 'Send Invite' })).toBeEnabled(); + }); + + it('FE-MOB-MVACINV-006: sending posts the picked user, toasts and closes', async () => { + let invitedId: number | undefined; + server.use(http.post('/api/addons/vacay/invite', async ({ request }) => { + invitedId = ((await request.json()) as { user_id: number }).user_id; + return HttpResponse.json({ success: true }); + })); + const { onClose } = renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'bob' })); + fireEvent.click(screen.getByRole('button', { name: 'Send Invite' })); + + await waitFor(() => expect(invitedId).toBe(2)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(toasts).toEqual([{ type: 'success', message: 'Invite sent' }]); + }); + + it('FE-MOB-MVACINV-007: a rejected invite surfaces the server error and keeps the sheet open', async () => { + server.use(http.post('/api/addons/vacay/invite', () => + HttpResponse.json({ error: 'Already fused' }, { status: 409 }))); + const { onClose } = renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'bob' })); + fireEvent.click(screen.getByRole('button', { name: 'Send Invite' })); + + await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Already fused' }])); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('FE-MOB-MVACINV-008: pending invites are listed and can be withdrawn', async () => { + const cancelInvite = vi.fn(async (_userId: number) => {}); + useVacayStore.setState({ + pendingInvites: [{ user_id: 9, username: 'dan' }], + cancelInvite, + }); + renderSheet(); + + const row = (await screen.findByText('dan')).closest('div') as HTMLElement; + expect(within(row).getByText('(pending)')).toBeInTheDocument(); + + fireEvent.click(within(row).getByRole('button', { name: 'Cancel' })); + expect(cancelInvite).toHaveBeenCalledWith(9); + }); + + it('FE-MOB-MVACINV-009: the close icon and the cancel button both dismiss the sheet', async () => { + const { onClose } = renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Close' })); + const dialog = screen.getByRole('dialog', { name: 'Invite User' }); + fireEvent.click(within(dialog).getAllByRole('button', { name: 'Cancel' })[0]); + expect(onClose).toHaveBeenCalledTimes(2); + }); + + it('FE-MOB-MVACINV-010: reopening resets the previous selection', async () => { + const { rerender } = render( {}} />); + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'bob' })); + expect(screen.getByRole('button', { name: 'bob' })).toBeInTheDocument(); + + rerender( {}} />); + rerender( {}} />); + + expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument(); + }); +}); diff --git a/client/tests/unit/mobile/vacay/MVacayMonth.segments.test.tsx b/client/tests/unit/mobile/vacay/MVacayMonth.segments.test.tsx new file mode 100644 index 000000000..156462ef9 --- /dev/null +++ b/client/tests/unit/mobile/vacay/MVacayMonth.segments.test.tsx @@ -0,0 +1,75 @@ +// FE-MOB-MVACMS-001 to FE-MOB-MVACMS-002 +import React from 'react'; +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import MVacayMonth from '../../../../src/mobile/screens/vacay/MVacayMonth'; +import { personTint } from '../../../../src/mobile/screens/vacay/vacayDayModel'; +import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel'; + +/** jsdom rewrites hsl() to rgb() on assignment — compare through the same path. */ +function asRendered(css: string): string { + const probe = document.createElement('div'); + probe.style.background = css; + return probe.style.background; +} + +const base: DayVisualContext = { + todayStr: '2099-01-01', + entryMap: {}, + companyHolidaySet: new Set(), + companyHolidaysEnabled: true, + holidays: {}, + weekendDays: [0, 6], +}; + +function renderMonth(ctx: DayVisualContext) { + return render( + {}} + />, + ); +} + +describe('MVacayMonth segments', () => { + it('FE-MOB-MVACMS-001: several people share the cell as equal-width segments', () => { + renderMonth({ + ...base, + entryMap: { + '2026-06-15': [ + { date: '2026-06-15', user_id: 1, person_color: '#3b82f6' }, + { date: '2026-06-15', user_id: 2, person_color: '#ec4899', kind: 'comp' }, + ], + }, + }); + + const cell = screen.getByRole('button', { name: '2026-06-15' }); + const segments = [...cell.querySelectorAll('.absolute.top-0')]; + + expect(segments).toHaveLength(2); + expect(segments[0].style.width).toBe('50%'); + expect(segments[1].style.left).toBe('50%'); + // The vacation segment is a solid tint, the comp one is hatched. + expect(segments[0].style.background).toBe(asRendered(personTint('#3b82f6'))); + expect(segments[1].style.background).toContain('repeating-linear-gradient(45deg'); + // With overlays the cell itself stays transparent underneath them. + expect(cell.style.background).toBe('transparent'); + }); + + it('FE-MOB-MVACMS-002: a single person fills the cell without overlays', () => { + renderMonth({ + ...base, + entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, person_color: '#3b82f6' }] }, + }); + + const cell = screen.getByRole('button', { name: '2026-06-15' }); + expect(cell.querySelectorAll('.absolute.top-0')).toHaveLength(0); + expect(cell.style.background).toBe(asRendered(personTint('#3b82f6'))); + }); +}); diff --git a/client/tests/unit/mobile/vacay/MVacaySettingsSheet.test.tsx b/client/tests/unit/mobile/vacay/MVacaySettingsSheet.test.tsx new file mode 100644 index 000000000..8ae795bd6 --- /dev/null +++ b/client/tests/unit/mobile/vacay/MVacaySettingsSheet.test.tsx @@ -0,0 +1,514 @@ +// FE-MOB-MVACSET-001 to FE-MOB-MVACSET-024 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useVacayStore } from '../../../../src/store/vacayStore'; +import MVacaySettingsSheet from '../../../../src/mobile/screens/vacay/MVacaySettingsSheet'; +import type { VacayHolidayCalendar, VacayPlan } from '../../../../src/types'; + +const toasts: { type: string; message: string }[] = []; + +function buildPlan(over: Partial = {}): VacayPlan { + return { + id: 1, + holidays_enabled: false, + school_holidays_enabled: false, + holidays_region: null, + holiday_calendars: [], + block_weekends: true, + carry_over_enabled: false, + company_holidays_enabled: false, + ...over, + }; +} + +/** The countries endpoint answers with nager's `{ countryCode, name }` rows. */ +function countriesRespond(rows: { countryCode: string; name: string }[]) { + server.use(http.get('/api/addons/vacay/holidays/countries', () => HttpResponse.json(rows))); +} + +/** Regional public holidays — `counties` is what turns the region picker on. */ +function regionalHolidays() { + server.use(http.get('/api/addons/vacay/holidays/:year/:country', () => HttpResponse.json([ + { date: '2026-11-01', name: 'All Saints', localName: 'Allerheiligen', global: false, counties: ['DE-BY', 'DE-NW'] }, + ]))); +} + +function seedPlan(plan: VacayPlan, extra: Record = {}) { + useVacayStore.setState({ plan, ...extra }); +} + +beforeEach(() => { + resetAllStores(); + toasts.length = 0; + window.__addToast = ((message: string, type?: string) => { + toasts.push({ type: type ?? 'info', message }); + return 1; + }) as Window['__addToast']; + countriesRespond([ + { countryCode: 'NL', name: 'Netherlands' }, + { countryCode: 'DE', name: 'Germany' }, + ]); + seedPlan(buildPlan()); +}); + +afterEach(() => { + delete window.__addToast; +}); + +function renderSheet(onClose = vi.fn(() => undefined)) { + const view = render(); + return { ...view, onClose }; +} + +/** Country picker options only appear once the countries request resolved. */ +async function waitForCountries() { + await waitFor(() => expect(screen.getAllByRole('option', { name: 'Germany' }).length).toBeGreaterThan(0)); +} + +describe('MVacaySettingsSheet', () => { + it('FE-MOB-MVACSET-001: renders nothing without a plan', () => { + useVacayStore.setState({ plan: null }); + render( {}} />); + + expect(screen.queryByRole('dialog', { name: 'Settings' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-002: renders nothing while closed', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: 'Settings' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-003: the close icon dismisses the sheet', () => { + const { onClose } = renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MVACSET-004: shows the weekend-day chips only while weekends are blocked', () => { + renderSheet(); + + expect(screen.getByText('Weekend days')).toBeInTheDocument(); + const sat = screen.getByRole('button', { name: 'Sat' }); + expect(sat.className).toContain('bg-m-act'); + expect(screen.getByRole('button', { name: 'Wed' }).className).not.toContain('bg-m-act'); + }); + + it('FE-MOB-MVACSET-005: toggling block-weekends writes the inverted flag', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan(), { updatePlan }); + renderSheet(); + + fireEvent.click(screen.getByRole('switch', { name: 'Block Weekends' })); + expect(updatePlan).toHaveBeenCalledWith({ block_weekends: false }); + }); + + it('FE-MOB-MVACSET-006: an unblocked plan hides the weekend-day chips', () => { + seedPlan(buildPlan({ block_weekends: false })); + renderSheet(); + + expect(screen.queryByText('Weekend days')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-007: weekend chips add and remove their day from the plan', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan({ weekend_days: '0,6' }), { updatePlan }); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Fri' })); + expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '0,6,5' }); + + // 'Sun' is also a week-start button; the weekend chips come first in the DOM. + fireEvent.click(screen.getAllByRole('button', { name: 'Sun' })[0]); + expect(updatePlan).toHaveBeenLastCalledWith({ weekend_days: '6' }); + }); + + it('FE-MOB-MVACSET-008: the week-start buttons mark the active day and save the other', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan({ block_weekends: false }), { updatePlan }); + renderSheet(); + + expect(screen.getByRole('button', { name: 'Mon' }).className).toContain('bg-m-act'); + fireEvent.click(screen.getByRole('button', { name: 'Sun' })); + expect(updatePlan).toHaveBeenCalledWith({ week_start: 0 }); + }); + + it('FE-MOB-MVACSET-009: carry-over and company holidays toggle their plan flags', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan({ carry_over_enabled: true }), { updatePlan }); + renderSheet(); + + fireEvent.click(screen.getByRole('switch', { name: 'Carry Over' })); + expect(updatePlan).toHaveBeenLastCalledWith({ carry_over_enabled: false }); + + fireEvent.click(screen.getByRole('switch', { name: 'Company Holidays' })); + expect(updatePlan).toHaveBeenLastCalledWith({ company_holidays_enabled: true }); + }); + + it('FE-MOB-MVACSET-010: the calendar leave year shows its Jan–Dec window', () => { + useVacayStore.setState({ selectedYear: 2026 }); + renderSheet(); + + expect(screen.getByText('2026: Jan 2026 – Dec 2026')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Calendar' }).className).toContain('bg-m-act'); + expect(screen.queryByRole('combobox', { name: 'Start month' })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Hire date')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-011: switching to a fiscal year saves the type and reveals month/day', () => { + const updateYearSettings = vi.fn(async () => {}); + useVacayStore.setState({ + selectedYear: 2026, + updateYearSettings, + yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null }, + }); + renderSheet(); + + expect(screen.getByText('2026: Jul 2026 – Jun 2027')).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'Start month' })).toHaveValue('7'); + expect(screen.getByRole('combobox', { name: 'Day' })).toHaveValue('1'); + + fireEvent.change(screen.getByRole('combobox', { name: 'Start month' }), { target: { value: '4' } }); + expect(updateYearSettings).toHaveBeenLastCalledWith({ + year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: null, + }); + }); + + it('FE-MOB-MVACSET-012: a start day past the new month cap is clamped on save', () => { + const updateYearSettings = vi.fn(async () => {}); + useVacayStore.setState({ + updateYearSettings, + yearSettings: { year_type: 'fiscal', year_start_month: 1, year_start_day: 31, hire_date: null }, + }); + renderSheet(); + + // February only ever has 28 guaranteed days. + fireEvent.change(screen.getByRole('combobox', { name: 'Start month' }), { target: { value: '2' } }); + expect(updateYearSettings).toHaveBeenLastCalledWith({ + year_type: 'fiscal', year_start_month: 2, year_start_day: 28, hire_date: null, + }); + expect(screen.getAllByRole('option', { name: '31' })).toHaveLength(1); + }); + + it('FE-MOB-MVACSET-013: the day select saves the picked day', () => { + const updateYearSettings = vi.fn(async () => {}); + useVacayStore.setState({ + updateYearSettings, + yearSettings: { year_type: 'fiscal', year_start_month: 4, year_start_day: 1, hire_date: null }, + }); + renderSheet(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Day' }), { target: { value: '15' } }); + expect(updateYearSettings).toHaveBeenLastCalledWith({ + year_type: 'fiscal', year_start_month: 4, year_start_day: 15, hire_date: null, + }); + }); + + it('FE-MOB-MVACSET-014: the anniversary year is driven by the hire date field', () => { + const updateYearSettings = vi.fn(async () => {}); + useVacayStore.setState({ + updateYearSettings, + yearSettings: { year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2020-09-14' }, + }); + renderSheet(); + + const hire = screen.getByLabelText('Hire date'); + expect(hire).toHaveValue('2020-09-14'); + + fireEvent.change(hire, { target: { value: '2021-03-02' } }); + expect(updateYearSettings).toHaveBeenLastCalledWith({ + year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02', + }); + + fireEvent.change(hire, { target: { value: '' } }); + expect(updateYearSettings).toHaveBeenLastCalledWith({ + year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null, + }); + }); + + it('FE-MOB-MVACSET-015: picking a year type saves it', () => { + const updateYearSettings = vi.fn(async () => {}); + useVacayStore.setState({ updateYearSettings }); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Hire date' })); + expect(updateYearSettings).toHaveBeenCalledWith({ + year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null, + }); + }); + + it('FE-MOB-MVACSET-016: public-holiday calendars stay hidden until the toggle is on', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan(), { updatePlan }); + renderSheet(); + + expect(screen.queryByRole('button', { name: 'Add calendar' })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('switch', { name: 'Public Holidays' })); + expect(updatePlan).toHaveBeenCalledWith({ holidays_enabled: true }); + }); + + it('FE-MOB-MVACSET-017: an enabled plan lists its public calendars and the add button', async () => { + const calendars: VacayHolidayCalendar[] = [ + { id: 1, plan_id: 1, region: 'DE', label: 'Germany', color: '#fecaca', sort_order: 0 }, + { id: 2, plan_id: 1, region: 'NL|group:NL-NO', label: 'Noord', color: '#bbf7d0', sort_order: 1, type: 'school_holiday' }, + ]; + seedPlan(buildPlan({ holidays_enabled: true, holiday_calendars: calendars })); + renderSheet(); + await waitForCountries(); + + // Only the public calendar renders under the public-holiday section. + const labels = screen.getAllByPlaceholderText('Label (optional)'); + expect(labels).toHaveLength(1); + expect(labels[0]).toHaveValue('Germany'); + expect(screen.getAllByRole('button', { name: 'Add calendar' })).toHaveLength(1); + }); + + it('FE-MOB-MVACSET-018: editing a calendar label writes it back on blur only when it changed', async () => { + const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record) => {}); + seedPlan( + buildPlan({ + holidays_enabled: true, + holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: 'Germany', color: '#fecaca', sort_order: 0 }], + }), + { updateHolidayCalendar }, + ); + renderSheet(); + + const input = screen.getByDisplayValue('Germany'); + fireEvent.blur(input); + expect(updateHolidayCalendar).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: ' Deutschland ' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + fireEvent.blur(input); + expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { label: 'Deutschland' }); + + fireEvent.change(input, { target: { value: ' ' } }); + fireEvent.blur(input); + expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { label: null }); + }); + + it('FE-MOB-MVACSET-019: the swatch palette recolors a calendar and closes again', () => { + const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record) => {}); + seedPlan( + buildPlan({ + holidays_enabled: true, + holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }], + }), + { updateHolidayCalendar }, + ); + renderSheet(); + + // The sheet renders through a portal, so the palette lives outside `container`. + fireEvent.click(screen.getByRole('button', { name: 'Color' })); + const swatches = document.querySelectorAll('.rounded-md'); + expect(swatches).toHaveLength(8); + + // Re-picking the current color is a no-op; a different one saves. + fireEvent.click(swatches[0]); + expect(updateHolidayCalendar).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Color' })); + fireEvent.click(document.querySelectorAll('.rounded-md')[3]); + expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { color: '#bbf7d0' }); + expect(document.querySelectorAll('.rounded-md')).toHaveLength(0); + }); + + it('FE-MOB-MVACSET-020: a calendar can change country and, once regional, its region', async () => { + regionalHolidays(); + const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record) => {}); + seedPlan( + buildPlan({ + holidays_enabled: true, + holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }], + }), + { updateHolidayCalendar }, + ); + renderSheet(); + await waitForCountries(); + + fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'NL' } }); + expect(updateHolidayCalendar).toHaveBeenCalledWith(3, { region: 'NL' }); + + const region = await screen.findByRole('combobox', { name: 'Select region (optional)' }); + fireEvent.change(region, { target: { value: 'DE-BY' } }); + expect(updateHolidayCalendar).toHaveBeenLastCalledWith(3, { region: 'DE-BY' }); + }); + + it('FE-MOB-MVACSET-021: a calendar can be deleted', () => { + const deleteHolidayCalendar = vi.fn(async (_id: number) => {}); + seedPlan( + buildPlan({ + holidays_enabled: true, + holiday_calendars: [{ id: 3, plan_id: 1, region: 'DE', label: null, color: '#fecaca', sort_order: 0 }], + }), + { deleteHolidayCalendar }, + ); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(deleteHolidayCalendar).toHaveBeenCalledWith(3); + }); + + it('FE-MOB-MVACSET-022: the add-calendar draft opens, cancels and posts a public calendar', async () => { + const addHolidayCalendar = vi.fn(async (_data: Record) => {}); + seedPlan(buildPlan({ holidays_enabled: true }), { addHolidayCalendar }); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Add calendar' })); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Add calendar' })); + await waitForCountries(); + fireEvent.change(screen.getByPlaceholderText('Label (optional)'), { target: { value: ' Feiertage ' } }); + fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({ + region: 'DE', color: '#fecaca', label: 'Feiertage', type: 'public_holiday', + })); + await waitFor(() => expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument()); + }); + + it('FE-MOB-MVACSET-023: a regional country blocks the draft until a region is picked', async () => { + regionalHolidays(); + const addHolidayCalendar = vi.fn(async (_data: Record) => {}); + seedPlan(buildPlan({ holidays_enabled: true }), { addHolidayCalendar }); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Add calendar' })); + await waitForCountries(); + fireEvent.change(screen.getByRole('combobox', { name: 'Select country' }), { target: { value: 'DE' } }); + + const region = await screen.findByRole('combobox', { name: 'Select region (optional)' }); + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled(); + + fireEvent.change(region, { target: { value: 'DE-NW' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({ + region: 'DE-NW', color: '#fecaca', label: null, type: 'public_holiday', + })); + }); + + it('FE-MOB-MVACSET-024: the school-holiday section only offers supported countries', async () => { + const addHolidayCalendar = vi.fn(async (_data: Record) => {}); + countriesRespond([ + { countryCode: 'NL', name: 'Netherlands' }, + { countryCode: 'US', name: 'United States' }, + ]); + seedPlan(buildPlan({ school_holidays_enabled: true }), { addHolidayCalendar }); + renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Add calendar' })); + const country = screen.getByRole('combobox', { name: 'Select country' }); + await waitFor(() => expect(within(country).getByRole('option', { name: 'Netherlands' })).toBeInTheDocument()); + expect(within(country).queryByRole('option', { name: 'United States' })).not.toBeInTheDocument(); + + fireEvent.change(country, { target: { value: 'NL' } }); + const region = await screen.findByRole('combobox', { name: 'Select region (optional)' }); + fireEvent.change(region, { target: { value: 'NL|group:NL-NO' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => expect(addHolidayCalendar).toHaveBeenCalledWith({ + region: 'NL|group:NL-NO', color: '#fecaca', label: null, type: 'school_holiday', + })); + }); + + it('FE-MOB-MVACSET-024b: an existing school calendar is editable with its subdivision regions', async () => { + server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () => HttpResponse.json({ + groups: [], + subdivisions: [ + { code: 'DE-BY', shortName: 'BY', name: [{ language: 'EN', text: 'Bavaria' }] }, + { code: 'DE-NW', shortName: 'NW', name: [{ language: 'EN', text: 'North Rhine-Westphalia' }] }, + ], + }))); + const updateHolidayCalendar = vi.fn(async (_id: number, _data: Record) => {}); + const deleteHolidayCalendar = vi.fn(async (_id: number) => {}); + seedPlan( + buildPlan({ + school_holidays_enabled: true, + holiday_calendars: [ + { id: 5, plan_id: 1, region: 'DE-BY', label: 'Bayern', color: '#bbf7d0', sort_order: 0, type: 'school_holiday' }, + // A calendar that never got a country has nothing to look regions up with. + { id: 6, plan_id: 1, region: '', label: null, color: '#fde68a', sort_order: 1, type: 'school_holiday' }, + ], + }), + { updateHolidayCalendar, deleteHolidayCalendar }, + ); + renderSheet(); + + expect(screen.getAllByPlaceholderText('Label (optional)')).toHaveLength(2); + const region = await screen.findByRole('combobox', { name: 'Select region (optional)' }); + fireEvent.change(region, { target: { value: 'DE-NW' } }); + expect(updateHolidayCalendar).toHaveBeenCalledWith(5, { region: 'DE-NW' }); + + fireEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]); + expect(deleteHolidayCalendar).toHaveBeenCalledWith(6); + }); + + it('FE-MOB-MVACSET-024c: the school draft can be cancelled again', () => { + seedPlan(buildPlan({ school_holidays_enabled: true })); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Add calendar' })); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByRole('button', { name: 'Add' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add calendar' })).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-025: the school-holiday toggle writes its own plan flag', () => { + const updatePlan = vi.fn(async (_updates: Partial) => {}); + seedPlan(buildPlan(), { updatePlan }); + renderSheet(); + + fireEvent.click(screen.getByRole('switch', { name: 'School Holidays' })); + expect(updatePlan).toHaveBeenCalledWith({ school_holidays_enabled: true }); + }); + + it('FE-MOB-MVACSET-026: the dissolve block appears only for a fused plan', () => { + renderSheet(); + expect(screen.queryByText('Dissolve Fusion')).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSET-027: dissolving lists the members, toasts and closes the sheet', async () => { + const dissolve = vi.fn(async () => {}); + seedPlan(buildPlan(), { + isFused: true, + dissolve, + users: [{ id: 1, username: 'alice', color: '#3b82f6' }, { id: 2, username: 'bob', color: null }], + }); + const { onClose } = renderSheet(); + + expect(screen.getByText('Dissolve Fusion')).toBeInTheDocument(); + expect(screen.getByText('alice')).toBeInTheDocument(); + expect(screen.getByText('bob')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Dissolve' })); + await waitFor(() => expect(dissolve).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + expect(toasts).toEqual([{ type: 'success', message: 'Calendar separated' }]); + }); + + it('FE-MOB-MVACSET-028: a failing countries request leaves the picker empty', async () => { + server.use(http.get('/api/addons/vacay/holidays/countries', () => + HttpResponse.json({ error: 'down' }, { status: 500 }))); + seedPlan(buildPlan({ holidays_enabled: true })); + renderSheet(); + + fireEvent.click(screen.getByRole('button', { name: 'Add calendar' })); + const country = screen.getByRole('combobox', { name: 'Select country' }); + await waitFor(() => expect(within(country).getAllByRole('option')).toHaveLength(1)); + // Only the disabled placeholder option survives — the label falls back to it too. + expect(screen.getAllByText('Select country')).toHaveLength(2); + }); +}); diff --git a/client/tests/unit/mobile/vacay/MVacayShareSheet.test.tsx b/client/tests/unit/mobile/vacay/MVacayShareSheet.test.tsx new file mode 100644 index 000000000..59a750f1b --- /dev/null +++ b/client/tests/unit/mobile/vacay/MVacayShareSheet.test.tsx @@ -0,0 +1,174 @@ +// FE-MOB-MVACSHR-001 to FE-MOB-MVACSHR-011 +import React from 'react'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { http, HttpResponse } from 'msw'; +import { render, screen, fireEvent, waitFor, within } from '../../../helpers/render'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useVacayStore } from '../../../../src/store/vacayStore'; +import MVacayShareSheet from '../../../../src/mobile/screens/vacay/MVacayShareSheet'; + +const toasts: { type: string; message: string }[] = []; + +function shareCandidates(users: { id: number; username: string }[]) { + server.use(http.get('/api/addons/vacay/shares/available-users', () => HttpResponse.json({ users }))); +} + +beforeEach(() => { + resetAllStores(); + toasts.length = 0; + window.__addToast = ((message: string, type?: string) => { + toasts.push({ type: type ?? 'info', message }); + return 1; + }) as Window['__addToast']; + shareCandidates([{ id: 2, username: 'bob' }, { id: 3, username: 'carol' }]); +}); + +afterEach(() => { + delete window.__addToast; +}); + +function renderSheet(onClose = vi.fn(() => undefined)) { + const view = render(); + return { ...view, onClose }; +} + +describe('MVacayShareSheet', () => { + it('FE-MOB-MVACSHR-001: renders nothing while closed', () => { + render( {}} />); + + expect(screen.queryByRole('dialog', { name: 'Shared Calendars' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSHR-002: loads the share candidates and explains what sharing does', async () => { + renderSheet(); + + expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument(); + expect(screen.getByText(/View only — no merge, no editing\./)).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSHR-003: shows the empty state when nobody is left to share with', async () => { + shareCandidates([]); + renderSheet(); + + expect(await screen.findByText('No users available')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Share' })).not.toBeInTheDocument(); + }); + + it('FE-MOB-MVACSHR-004: keeps the empty state when the lookup fails', async () => { + server.use(http.get('/api/addons/vacay/shares/available-users', () => + HttpResponse.json({ error: 'nope' }, { status: 500 }))); + renderSheet(); + + expect(await screen.findByText('No users available')).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSHR-005: the share button unlocks once a user is picked', async () => { + renderSheet(); + expect(await screen.findByRole('button', { name: 'Share' })).toBeDisabled(); + + fireEvent.click(screen.getByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'carol' })); + expect(screen.getByRole('button', { name: 'Share' })).toBeEnabled(); + }); + + it('FE-MOB-MVACSHR-006: sharing posts the user, toasts and clears the selection', async () => { + let sharedWith: number | undefined; + server.use(http.post('/api/addons/vacay/shares', async ({ request }) => { + sharedWith = ((await request.json()) as { user_id: number }).user_id; + return HttpResponse.json({ success: true }); + })); + renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'carol' })); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + + await waitFor(() => expect(sharedWith).toBe(3)); + await waitFor(() => expect(toasts).toEqual([{ type: 'success', message: 'Calendar shared' }])); + expect(await screen.findByRole('button', { name: 'Select user' })).toBeInTheDocument(); + }); + + it('FE-MOB-MVACSHR-007: a rejected share surfaces the server error', async () => { + server.use(http.post('/api/addons/vacay/shares', () => + HttpResponse.json({ error: 'Already shared' }, { status: 409 }))); + renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Select user' })); + fireEvent.click(screen.getByRole('button', { name: 'bob' })); + fireEvent.click(screen.getByRole('button', { name: 'Share' })); + + await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Already shared' }])); + }); + + it('FE-MOB-MVACSHR-008: incoming shares toggle their overlay through the eye button', async () => { + const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => {}); + useVacayStore.setState({ + incomingShares: [ + { id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }, + { id: 6, owner_id: 4, username: 'dan', color: '#10b981', hidden: true }, + ], + setShareHidden, + }); + renderSheet(); + + expect(await screen.findByText('Shared with you')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Hide from calendar' })); + fireEvent.click(screen.getByRole('button', { name: 'Show in calendar' })); + + expect(setShareHidden).toHaveBeenNthCalledWith(1, 5, true); + expect(setShareHidden).toHaveBeenNthCalledWith(2, 6, false); + }); + + it('FE-MOB-MVACSHR-009: a failing hide toggle reports the error', async () => { + const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => { + throw { response: { data: { error: 'Share vanished' } }, message: 'nope' }; + }); + useVacayStore.setState({ + incomingShares: [{ id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }], + setShareHidden, + }); + renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Hide from calendar' })); + await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Share vanished' }])); + }); + + it('FE-MOB-MVACSHR-010: both share directions can be revoked', async () => { + const removeShare = vi.fn(async (_shareId: number) => {}); + useVacayStore.setState({ + incomingShares: [{ id: 5, owner_id: 3, username: 'carol', color: '#f59e0b', hidden: false }], + outgoingShares: [{ id: 11, user_id: 8, username: 'erin' }], + removeShare, + }); + renderSheet(); + + expect(await screen.findByText('You share with')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Remove' })); + fireEvent.click(screen.getByRole('button', { name: 'Stop sharing' })); + + expect(removeShare).toHaveBeenNthCalledWith(1, 5); + expect(removeShare).toHaveBeenNthCalledWith(2, 11); + }); + + it('FE-MOB-MVACSHR-011: a failing revoke reports the fallback message', async () => { + // No `response` and not an Error — getApiErrorMessage falls through to the label. + const removeShare = vi.fn(async (_shareId: number) => { throw { status: 500 }; }); + useVacayStore.setState({ + outgoingShares: [{ id: 11, user_id: 8, username: 'erin' }], + removeShare, + }); + renderSheet(); + + fireEvent.click(await screen.findByRole('button', { name: 'Stop sharing' })); + await waitFor(() => expect(toasts).toEqual([{ type: 'error', message: 'Could not share calendar' }])); + }); + + it('FE-MOB-MVACSHR-012: the close icon dismisses the sheet', async () => { + const { onClose } = renderSheet(); + + const dialog = await screen.findByRole('dialog', { name: 'Shared Calendars' }); + fireEvent.click(within(dialog).getByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/tests/unit/mobile/vacay/useMVacay.test.tsx b/client/tests/unit/mobile/vacay/useMVacay.test.tsx new file mode 100644 index 000000000..077a9e196 --- /dev/null +++ b/client/tests/unit/mobile/vacay/useMVacay.test.tsx @@ -0,0 +1,427 @@ +// FE-MOB-MVAC-001 to FE-MOB-MVAC-025 +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; +import { server } from '../../../helpers/msw/server'; +import { resetAllStores } from '../../../helpers/store'; +import { useMVacay } from '../../../../src/mobile/screens/vacay/useMVacay'; +import { useVacayStore } from '../../../../src/store/vacayStore'; +import { useAuthStore } from '../../../../src/store/authStore'; +import type { VacayEntry, VacayPlan, VacayStat, VacayUser } from '../../../../src/types'; + +const navigateMock = vi.fn(); + +vi.mock('react-router-dom', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigateMock }; +}); + +const mocks = vi.hoisted(() => ({ vacay: {} as Record })); + +vi.mock('../../../../src/pages/vacay/useVacay', () => ({ + useVacay: () => mocks.vacay, +})); + +const toasts: { type: string; message: string }[] = []; + +const alice: VacayUser = { id: 1, username: 'alice', color: '#3b82f6' }; +const bob: VacayUser = { id: 2, username: 'bob', color: '#ec4899' }; + +function buildPlan(over: Partial = {}): VacayPlan { + return { + id: 1, + holidays_enabled: false, + holidays_region: null, + holiday_calendars: [], + block_weekends: true, + carry_over_enabled: false, + company_holidays_enabled: true, + ...over, + }; +} + +function buildStat(over: Partial = {}): VacayStat { + return { + user_id: 1, person_name: 'alice', person_color: '#3b82f6', year: 2026, + vacation_days: 30, carried_over: 0, total_available: 30, used: 4, remaining: 26, + ...over, + }; +} + +function buildVacay(over: Record = {}): Record { + return { + years: [2025, 2026], + selectedYear: 2026, + setSelectedYear: vi.fn((_year: number) => undefined), + loading: false, + incomingInvites: [], + acceptInvite: vi.fn(async (_planId: number) => {}), + declineInvite: vi.fn(async (_planId: number) => {}), + plan: buildPlan(), + handleAddNextYear: vi.fn(() => undefined), + handleAddPrevYear: vi.fn(() => undefined), + ...over, + }; +} + +function tripsRespond(trips: { start_date?: string | null; end_date?: string | null }[]) { + server.use(http.get('/api/trips', () => HttpResponse.json({ trips }))); +} + +beforeEach(() => { + resetAllStores(); + navigateMock.mockClear(); + toasts.length = 0; + window.__addToast = ((message: string, type?: string) => { + toasts.push({ type: type ?? 'info', message }); + return 1; + }) as Window['__addToast']; + mocks.vacay = buildVacay(); + tripsRespond([]); + useAuthStore.setState({ user: { id: 1, username: 'alice', email: 'a@t.app', role: 'user' } as never }); + useVacayStore.setState({ + users: [alice, bob], + entries: [], + companyHolidays: [], + stats: [buildStat()], + holidays: {}, + sharedCalendars: [], + incomingShares: [], + selectedUserId: null, + isFused: false, + }); +}); + +afterEach(() => { + delete window.__addToast; +}); + +async function mount() { + const view = renderHook(() => useMVacay()); + await waitFor(() => expect(view.result.current.selectedUserId).toBe(1)); + return view; +} + +describe('useMVacay', () => { + it('FE-MOB-MVAC-001: defaults the active person to the signed-in user', async () => { + const { result } = await mount(); + + expect(useVacayStore.getState().selectedUserId).toBe(1); + expect(result.current.selectedUser?.username).toBe('alice'); + expect(result.current.selectedColor).toBe('#3b82f6'); + expect(result.current.selectedStat?.remaining).toBe(26); + expect(result.current.tripDotColor).toBe('#3b82f6'); + }); + + it('FE-MOB-MVAC-002: falls back to the shared person color when the member has none', async () => { + act(() => { useVacayStore.setState({ users: [{ id: 1, username: 'alice', color: null }] }); }); + const { result } = await mount(); + + expect(result.current.selectedColor).toBe('#6366f1'); + expect(result.current.tripDotColor).toBe('var(--m-st-info)'); + }); + + it('FE-MOB-MVAC-003: collects every day covered by an own trip inside the window', async () => { + tripsRespond([ + { start_date: '2026-03-30', end_date: '2026-04-02' }, + { start_date: '2025-12-30', end_date: '2025-12-31' }, + { start_date: null, end_date: '2026-05-01' }, + ]); + const { result } = await mount(); + + await waitFor(() => expect(result.current.tripDates.size).toBe(4)); + expect([...result.current.tripDates].sort()).toEqual([ + '2026-03-30', '2026-03-31', '2026-04-01', '2026-04-02', + ]); + expect(result.current.tripDates.has('2025-12-30')).toBe(false); + }); + + it('FE-MOB-MVAC-004: leaves the trip overlay empty when the trip list fails', async () => { + server.use(http.get('/api/trips', () => HttpResponse.json({ error: 'boom' }, { status: 500 }))); + const { result } = await mount(); + + await waitFor(() => expect(result.current.tripDates.size).toBe(0)); + }); + + it('FE-MOB-MVAC-005: derives the plan flags from their defaults without a plan', async () => { + mocks.vacay = buildVacay({ plan: null }); + const { result } = await mount(); + + expect(result.current.blockWeekends).toBe(true); + expect(result.current.companyHolidaysEnabled).toBe(true); + expect(result.current.holidaysEnabled).toBe(false); + expect(result.current.weekStart).toBe(1); + expect(result.current.weekendDays).toEqual([0, 6]); + }); + + it('FE-MOB-MVAC-006: reads configured weekend days and week start off the plan', async () => { + mocks.vacay = buildVacay({ + plan: buildPlan({ + weekend_days: '5,6', week_start: 0, block_weekends: false, + company_holidays_enabled: false, holidays_enabled: true, + }), + }); + const { result } = await mount(); + + expect(result.current.weekendDays).toEqual([5, 6]); + expect(result.current.weekStart).toBe(0); + expect(result.current.blockWeekends).toBe(false); + expect(result.current.companyHolidaysEnabled).toBe(false); + expect(result.current.holidaysEnabled).toBe(true); + }); + + it('FE-MOB-MVAC-007: groups entries per date and indexes the company holidays', async () => { + const entries: VacayEntry[] = [ + { date: '2026-06-15', user_id: 1 }, + { date: '2026-06-15', user_id: 2, fraction: 0.5 }, + { date: '2026-06-16', user_id: 1, kind: 'comp' }, + ]; + act(() => { useVacayStore.setState({ entries, companyHolidays: [{ date: '2026-12-24' }] }); }); + const { result } = await mount(); + + expect(result.current.dayCtx.entryMap['2026-06-15']).toHaveLength(2); + expect(result.current.dayCtx.entryMap['2026-06-16']).toHaveLength(1); + expect(result.current.dayCtx.companyHolidaySet.has('2026-12-24')).toBe(true); + }); + + it('FE-MOB-MVAC-008: overlays visible shared calendars only', async () => { + act(() => { + useVacayStore.setState({ + sharedCalendars: [ + { + share_id: 1, owner_id: 3, owner_name: 'carol', color: '#f59e0b', hidden: false, + entries: [{ date: '2026-07-01' }], companyHolidays: [{ date: '2026-07-02' }], + }, + { + share_id: 2, owner_id: 4, owner_name: 'dan', color: '#10b981', hidden: true, + entries: [{ date: '2026-07-03' }], companyHolidays: [], + }, + ], + }); + }); + const { result } = await mount(); + + expect(result.current.dayCtx.sharedMap?.['2026-07-01']).toEqual([{ color: '#f59e0b' }]); + expect(result.current.dayCtx.sharedMap?.['2026-07-02']).toEqual([{ color: '#f59e0b' }]); + expect(result.current.dayCtx.sharedMap?.['2026-07-03']).toBeUndefined(); + }); + + it('FE-MOB-MVAC-009: spans Jan–Dec on a calendar leave year', async () => { + const { result } = await mount(); + + expect(result.current.isShiftedYear).toBe(false); + expect(result.current.months).toHaveLength(12); + expect(result.current.months[0]).toEqual({ year: 2026, month: 0 }); + expect(result.current.months[11]).toEqual({ year: 2026, month: 11 }); + expect(result.current.monthNamesShort[0]).toBe('Jan'); + }); + + it('FE-MOB-MVAC-010: rolls into the next calendar year for a fiscal leave year', async () => { + act(() => { + useVacayStore.setState({ + yearSettings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1, hire_date: null }, + }); + }); + const { result } = await mount(); + + expect(result.current.isShiftedYear).toBe(true); + expect(result.current.months[0]).toEqual({ year: 2026, month: 6 }); + expect(result.current.months[11]).toEqual({ year: 2027, month: 5 }); + }); + + it('FE-MOB-MVAC-011: starts on the slot holding the current month', async () => { + const { result } = await mount(); + + expect(result.current.monthSlot).toBe(new Date().getMonth()); + expect(result.current.activeMonth.month).toBe(new Date().getMonth()); + expect(result.current.monthNameLong).toBe( + new Intl.DateTimeFormat('en-US', { month: 'long' }).format(new Date(2026, new Date().getMonth(), 1)), + ); + }); + + it('FE-MOB-MVAC-012: wraps the month stepper inside the twelve slots', async () => { + const { result } = await mount(); + + act(() => { result.current.setMonthSlot(11); }); + act(() => { result.current.nextMonth(); }); + expect(result.current.monthSlot).toBe(0); + + act(() => { result.current.prevMonth(); }); + expect(result.current.monthSlot).toBe(11); + }); + + it('FE-MOB-MVAC-013: only a fused plan allows selecting another member', async () => { + const { result } = await mount(); + + act(() => { result.current.selectPerson(2); }); + expect(useVacayStore.getState().selectedUserId).toBe(1); + + act(() => { useVacayStore.setState({ isFused: true }); }); + act(() => { result.current.selectPerson(2); }); + expect(useVacayStore.getState().selectedUserId).toBe(2); + expect(result.current.selectedUser?.username).toBe('bob'); + }); + + it('FE-MOB-MVAC-014: selecting yourself resets the logging mode to vacation', async () => { + const { result } = await mount(); + + act(() => { result.current.setMode('company'); }); + expect(result.current.mode).toBe('company'); + + act(() => { result.current.selectPerson(1); }); + expect(result.current.mode).toBe('vacation'); + }); + + it('FE-MOB-MVAC-015: the year overview never logs a day', async () => { + const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {}); + act(() => { useVacayStore.setState({ toggleEntry }); }); + const { result } = await mount(); + + expect(result.current.view).toBe('grid'); + await act(async () => { await result.current.handleDayTap('2026-06-15'); }); + expect(toggleEntry).not.toHaveBeenCalled(); + }); + + it('FE-MOB-MVAC-016: logs the selected person with the half-day and comp modifiers', async () => { + const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {}); + act(() => { useVacayStore.setState({ toggleEntry }); }); + const { result } = await mount(); + + act(() => { result.current.toggleView(); }); + expect(result.current.view).toBe('edit'); + + await act(async () => { await result.current.handleDayTap('2026-06-15'); }); + expect(toggleEntry).toHaveBeenLastCalledWith('2026-06-15', 1, 1, 'vacation'); + + act(() => { result.current.setHalfDay(true); result.current.setCompDay(true); }); + await act(async () => { await result.current.handleDayTap('2026-06-16'); }); + expect(toggleEntry).toHaveBeenLastCalledWith('2026-06-16', 1, 0.5, 'comp'); + }); + + it('FE-MOB-MVAC-017: blocked weekends and company holidays swallow the tap', async () => { + const toggleEntry = vi.fn(async (_date: string, _userId?: number, _fraction?: 0.5 | 1, _kind?: 'vacation' | 'comp') => {}); + act(() => { useVacayStore.setState({ toggleEntry, companyHolidays: [{ date: '2026-12-24' }] }); }); + const { result } = await mount(); + act(() => { result.current.toggleView(); }); + + // 2026-06-13 is a Saturday. + await act(async () => { await result.current.handleDayTap('2026-06-13'); }); + await act(async () => { await result.current.handleDayTap('2026-12-24'); }); + expect(toggleEntry).not.toHaveBeenCalled(); + + await act(async () => { await result.current.handleDayTap('2026-06-15'); }); + expect(toggleEntry).toHaveBeenCalledTimes(1); + }); + + it('FE-MOB-MVAC-018: company mode toggles a company holiday and is inert while disabled', async () => { + const toggleCompanyHoliday = vi.fn(async (_date: string) => {}); + act(() => { useVacayStore.setState({ toggleCompanyHoliday }); }); + mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: false }) }); + const { result, rerender } = await mount(); + + act(() => { result.current.toggleView(); }); + act(() => { result.current.setMode('company'); }); + await act(async () => { await result.current.handleDayTap('2026-12-24'); }); + expect(toggleCompanyHoliday).not.toHaveBeenCalled(); + + mocks.vacay = buildVacay({ plan: buildPlan({ company_holidays_enabled: true }) }); + act(() => { rerender(); }); + await act(async () => { await result.current.handleDayTap('2026-12-24'); }); + expect(toggleCompanyHoliday).toHaveBeenCalledWith('2026-12-24'); + }); + + it('FE-MOB-MVAC-019: the entitlement stepper caps at 365 and never drops below used', async () => { + const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {}); + act(() => { useVacayStore.setState({ updateVacationDays, stats: [buildStat({ vacation_days: 365 })] }); }); + const { result } = await mount(); + + act(() => { result.current.allowInc(); }); + expect(updateVacationDays).toHaveBeenLastCalledWith(2026, 365, 1); + + act(() => { result.current.allowDec(); }); + expect(updateVacationDays).toHaveBeenLastCalledWith(2026, 364, 1); + + act(() => { useVacayStore.setState({ stats: [buildStat({ vacation_days: 10, used: 10, carried_over: 0 })] }); }); + act(() => { result.current.allowDec(); }); + expect(updateVacationDays).toHaveBeenCalledTimes(2); + }); + + it('FE-MOB-MVAC-020: carried-over days let the entitlement drop below what was used', async () => { + const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {}); + act(() => { + useVacayStore.setState({ updateVacationDays, stats: [buildStat({ vacation_days: 10, used: 12, carried_over: 5 })] }); + }); + const { result } = await mount(); + + act(() => { result.current.allowDec(); }); + expect(updateVacationDays).toHaveBeenCalledWith(2026, 9, 1); + }); + + it('FE-MOB-MVAC-021: the stepper is inert without a stat row for the person', async () => { + const updateVacationDays = vi.fn(async (_year: number, _days: number, _target?: number) => {}); + act(() => { useVacayStore.setState({ updateVacationDays, stats: [] }); }); + const { result } = await mount(); + + act(() => { result.current.allowInc(); }); + act(() => { result.current.allowDec(); }); + expect(updateVacationDays).not.toHaveBeenCalled(); + }); + + it('FE-MOB-MVAC-022: the year switcher steps inside the list before creating a year', async () => { + const setSelectedYear = vi.fn((_year: number) => undefined); + const handleAddPrevYear = vi.fn(() => undefined); + const handleAddNextYear = vi.fn(() => undefined); + mocks.vacay = buildVacay({ setSelectedYear, handleAddPrevYear, handleAddNextYear }); + const { result } = await mount(); + + await act(async () => { await result.current.prevYear(); }); + expect(setSelectedYear).toHaveBeenLastCalledWith(2025); + expect(handleAddPrevYear).not.toHaveBeenCalled(); + + await act(async () => { await result.current.nextYear(); }); + expect(handleAddNextYear).toHaveBeenCalledTimes(1); + expect(setSelectedYear).toHaveBeenLastCalledWith(2027); + }); + + it('FE-MOB-MVAC-022b: stepping forward inside the list never creates a year', async () => { + const setSelectedYear = vi.fn((_year: number) => undefined); + const handleAddNextYear = vi.fn(() => undefined); + mocks.vacay = buildVacay({ selectedYear: 2025, setSelectedYear, handleAddNextYear }); + const { result } = await mount(); + + await act(async () => { await result.current.nextYear(); }); + expect(setSelectedYear).toHaveBeenCalledWith(2026); + expect(handleAddNextYear).not.toHaveBeenCalled(); + }); + + it('FE-MOB-MVAC-023: stepping past the oldest year creates it first', async () => { + const setSelectedYear = vi.fn((_year: number) => undefined); + const handleAddPrevYear = vi.fn(() => undefined); + mocks.vacay = buildVacay({ years: [2026], selectedYear: 2026, setSelectedYear, handleAddPrevYear }); + const { result } = await mount(); + + await act(async () => { await result.current.prevYear(); }); + expect(handleAddPrevYear).toHaveBeenCalledTimes(1); + expect(setSelectedYear).toHaveBeenCalledWith(2025); + }); + + it('FE-MOB-MVAC-024: a rejected share toggle surfaces the server message', async () => { + const setShareHidden = vi.fn(async (_shareId: number, _hidden: boolean) => { + throw { response: { data: { error: 'Share gone' } }, message: 'nope' }; + }); + act(() => { useVacayStore.setState({ setShareHidden }); }); + const { result } = await mount(); + + await act(async () => { result.current.toggleShareHidden(7, true); }); + await waitFor(() => expect(toasts).toHaveLength(1)); + expect(toasts[0]).toEqual({ type: 'error', message: 'Share gone' }); + expect(setShareHidden).toHaveBeenCalledWith(7, true); + }); + + it('FE-MOB-MVAC-025: the back action returns to the dashboard', async () => { + const { result } = await mount(); + + act(() => { result.current.goBack(); }); + expect(navigateMock).toHaveBeenCalledWith('/dashboard'); + }); +}); diff --git a/client/tests/unit/mobile/vacay/vacayDayModel.colors.test.ts b/client/tests/unit/mobile/vacay/vacayDayModel.colors.test.ts new file mode 100644 index 000000000..960cd4627 --- /dev/null +++ b/client/tests/unit/mobile/vacay/vacayDayModel.colors.test.ts @@ -0,0 +1,76 @@ +// FE-MOB-MVACDM-001 to FE-MOB-MVACDM-007 +import { describe, it, expect } from 'vitest'; +import { dayVisual, hatchTint, holidayInk, personTint } from '../../../../src/mobile/screens/vacay/vacayDayModel'; +import type { DayVisualContext } from '../../../../src/mobile/screens/vacay/vacayDayModel'; + +function ctx(over: Partial = {}): DayVisualContext { + return { + todayStr: '2099-01-01', + entryMap: {}, + companyHolidaySet: new Set(), + companyHolidaysEnabled: true, + holidays: {}, + weekendDays: [0, 6], + ...over, + }; +} + +describe('vacayDayModel colors', () => { + it('FE-MOB-MVACDM-001: a colour that is not a plain hex is passed through untouched', () => { + expect(personTint('var(--m-act)')).toBe('var(--m-act)'); + expect(holidayInk('rgb(1,2,3)')).toBe('var(--m-ink)'); + }); + + it('FE-MOB-MVACDM-002: a neutral grey keeps hue and saturation at zero', () => { + expect(personTint('#808080')).toBe('hsl(0 0% 85%)'); + }); + + it('FE-MOB-MVACDM-003: the hue is derived from whichever channel dominates', () => { + // Red-, green- and blue-dominant inputs each take a different hue branch. + expect(personTint('#ef4444')).toMatch(/^hsl\(0 /); + expect(personTint('#22c55e')).toMatch(/^hsl\(142 /); + expect(personTint('#3b82f6')).toMatch(/^hsl\(217 /); + }); + + it('FE-MOB-MVACDM-004: a half day is flagged so the cell can badge it', () => { + const visual = dayVisual('2026-06-15', 1, ctx({ + entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, fraction: 0.5, person_color: '#3b82f6' }] }, + })); + + expect(visual.half).toBe(true); + expect(visual.segments).toEqual([{ color: personTint('#3b82f6'), comp: false }]); + }); + + it('FE-MOB-MVACDM-005: an all-comp day is hatched and carries its own text shadow', () => { + const visual = dayVisual('2026-06-15', 1, ctx({ + entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, kind: 'comp', person_color: '#3b82f6' }] }, + })); + + expect(visual.background).toBe(hatchTint(personTint('#3b82f6'))); + expect(visual.textShadow).toBeDefined(); + }); + + it('FE-MOB-MVACDM-006: a plain school-break day gets the wash and a tinted ink', () => { + const visual = dayVisual('2026-06-15', 1, ctx({ + holidays: { + '2026-06-15': [{ name: 'Sommer', localName: 'Sommer', color: '#bbf7d0', label: null, type: 'school_holiday' }], + }, + })); + + expect(visual.school).toEqual(['#bbf7d0']); + expect(visual.background).toBe('color-mix(in srgb, #bbf7d0 15%, transparent)'); + expect(visual.numColor).toBe(holidayInk('#bbf7d0')); + }); + + it('FE-MOB-MVACDM-007: a school break on a logged day only adds the accent band', () => { + const visual = dayVisual('2026-06-15', 1, ctx({ + entryMap: { '2026-06-15': [{ date: '2026-06-15', user_id: 1, person_color: '#3b82f6' }] }, + holidays: { + '2026-06-15': [{ name: 'Sommer', localName: 'Sommer', color: '#bbf7d0', label: null, type: 'school_holiday' }], + }, + })); + + expect(visual.school).toEqual(['#bbf7d0']); + expect(visual.background).toBe(personTint('#3b82f6')); + }); +});