diff --git a/client/src/components/Settings/AirTrailConnectionSection.tsx b/client/src/components/Settings/AirTrailConnectionSection.tsx index d984d4098..ddb780706 100644 --- a/client/src/components/Settings/AirTrailConnectionSection.tsx +++ b/client/src/components/Settings/AirTrailConnectionSection.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { Loader2, Plane, Save } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useToast } from '../shared/Toast' -import { airtrailApi } from '../../api/client' +import { useAirTrailSettings } from './useAirTrailSettings' import AirTrailIcon from '../shared/AirTrailIcon' import Section from './Section' import ToggleSwitch from './ToggleSwitch' @@ -18,69 +17,24 @@ import { fs } from '../shared/DialogShell' */ export default function AirTrailConnectionSection(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const [url, setUrl] = useState('') - const [apiKey, setApiKey] = useState('') - const [allowInsecureTls, setAllowInsecureTls] = useState(false) - const [writeEnabled, setWriteEnabled] = useState(false) - const [connected, setConnected] = useState(false) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) - - useEffect(() => { - airtrailApi - .getSettings() - .then(d => { - setUrl(d.url || '') - setAllowInsecureTls(!!d.allowInsecureTls) - setWriteEnabled(!!d.writeEnabled) - setConnected(!!d.connected) - }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, []) - - // Send the key only when the user typed a new one — never prefilled, so a blank - // field means "keep the stored key". - const keyPayload = (): { apiKey?: string } => { - const k = apiKey.trim() - return k ? { apiKey: k } : {} - } - - const handleSave = async () => { - setSaving(true) - try { - const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() }) - const status = await airtrailApi.status().catch(() => ({ connected: false })) - setConnected(!!status.connected) - setApiKey('') - if (d?.warning) toast.warning(d.warning) - else toast.success(t('settings.airtrail.toast.saved')) - } catch (err) { - const reason = (err as { response?: { data?: { error?: string } } } | null)?.response?.data?.error - toast.error(reason || t('settings.airtrail.toast.saveError')) - } finally { - setSaving(false) - } - } - - const handleTest = async () => { - setTesting(true) - try { - const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() }) - setConnected(!!d.connected) - if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 })) - else toast.error(d.error || t('settings.airtrail.test.failed')) - } catch { - toast.error(t('settings.airtrail.test.failed')) - } finally { - setTesting(false) - } - } - - const canSave = !!url.trim() && (connected || !!apiKey.trim()) + const { + url, + setUrl, + apiKey, + setApiKey, + allowInsecureTls, + toggleInsecureTls, + writeEnabled, + toggleWriteEnabled, + connected, + loading, + saving, + testing, + canSave, + handleSave, + handleTest, + } = useAirTrailSettings() const fieldId = (name: string) => `airtrail-${name}` @@ -122,12 +76,12 @@ export default function AirTrailConnectionSection(): React.ReactElement { setAllowInsecureTls(v => !v)} label={t('settings.airtrail.allowInsecureTls')} />} + control={} /> setWriteEnabled(v => !v)} label={t('settings.airtrail.writeBack')} />} + control={} /> diff --git a/client/src/components/Settings/useAirTrailSettings.test.ts b/client/src/components/Settings/useAirTrailSettings.test.ts new file mode 100644 index 000000000..921e2a9df --- /dev/null +++ b/client/src/components/Settings/useAirTrailSettings.test.ts @@ -0,0 +1,112 @@ +// FE-COMP-AIRTRAIL-HOOK-001 to -008: the AirTrail connection form both settings shells share. +import { act, renderHook, waitFor } from '@testing-library/react'; +import { airtrailApi } from '../../api/client'; +import { useAirTrailSettings } from './useAirTrailSettings'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ t: (k: string, p?: Record) => (p ? `${k}:${JSON.stringify(p)}` : k) }), +})); + +const STORED = { url: 'https://air.example', allowInsecureTls: true, writeEnabled: false, connected: true }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(airtrailApi, 'getSettings').mockResolvedValue(STORED); + vi.spyOn(airtrailApi, 'saveSettings').mockResolvedValue({}); + vi.spyOn(airtrailApi, 'status').mockResolvedValue({ connected: true }); + vi.spyOn(airtrailApi, 'test').mockResolvedValue({ connected: true, flightCount: 3 }); +}); +afterEach(() => vi.restoreAllMocks()); + +async function loaded() { + const hook = renderHook(() => useAirTrailSettings()); + await waitFor(() => expect(hook.result.current.loading).toBe(false)); + return hook; +} + +describe('useAirTrailSettings', () => { + it('FE-COMP-AIRTRAIL-HOOK-001: hydrates the form from the stored settings and never prefills the key', async () => { + const { result } = await loaded(); + expect(result.current.url).toBe('https://air.example'); + expect(result.current.allowInsecureTls).toBe(true); + expect(result.current.writeEnabled).toBe(false); + expect(result.current.connected).toBe(true); + expect(result.current.apiKey).toBe(''); + }); + + it('FE-COMP-AIRTRAIL-HOOK-002: a failed read still ends loading and leaves the defaults', async () => { + vi.mocked(airtrailApi.getSettings).mockRejectedValue(new Error('down')); + const { result } = await loaded(); + expect(result.current.url).toBe(''); + expect(result.current.connected).toBe(false); + }); + + it('FE-COMP-AIRTRAIL-HOOK-003: canSave needs a url plus a connection or a typed key', async () => { + vi.mocked(airtrailApi.getSettings).mockResolvedValue({ url: '', connected: false }); + const { result } = await loaded(); + expect(result.current.canSave).toBe(false); + act(() => result.current.setUrl('https://a.example')); + expect(result.current.canSave).toBe(false); + act(() => result.current.setApiKey(' k ')); + expect(result.current.canSave).toBe(true); + }); + + it('FE-COMP-AIRTRAIL-HOOK-004: save leaves a blank key out, refreshes the status and toasts success', async () => { + const { result } = await loaded(); + act(() => result.current.toggleWriteEnabled()); + act(() => result.current.toggleInsecureTls()); + await act(() => result.current.handleSave()); + expect(airtrailApi.saveSettings).toHaveBeenCalledWith({ + url: 'https://air.example', + allowInsecureTls: false, + writeEnabled: true, + }); + expect(airtrailApi.status).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith('settings.airtrail.toast.saved'); + expect(result.current.saving).toBe(false); + }); + + it('FE-COMP-AIRTRAIL-HOOK-005: save sends a typed key trimmed, clears it, and shows a server warning instead', async () => { + vi.mocked(airtrailApi.saveSettings).mockResolvedValue({ warning: 'careful' }); + vi.mocked(airtrailApi.status).mockRejectedValue(new Error('x')); + const { result } = await loaded(); + act(() => result.current.setApiKey(' secret ')); + await act(() => result.current.handleSave()); + expect(airtrailApi.saveSettings).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'secret' })); + expect(result.current.apiKey).toBe(''); + expect(result.current.connected).toBe(false); + expect(toast.warning).toHaveBeenCalledWith('careful'); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('FE-COMP-AIRTRAIL-HOOK-006: a refused save toasts the server reason, else the generic error', async () => { + vi.mocked(airtrailApi.saveSettings).mockRejectedValueOnce({ response: { data: { error: 'bad url' } } }); + const { result } = await loaded(); + await act(() => result.current.handleSave()); + expect(toast.error).toHaveBeenLastCalledWith('bad url'); + vi.mocked(airtrailApi.saveSettings).mockRejectedValueOnce(new Error('x')); + await act(() => result.current.handleSave()); + expect(toast.error).toHaveBeenLastCalledWith('settings.airtrail.toast.saveError'); + }); + + it('FE-COMP-AIRTRAIL-HOOK-007: test reports the flight count, or the probe error, and updates the connection', async () => { + const { result } = await loaded(); + await act(() => result.current.handleTest()); + expect(airtrailApi.test).toHaveBeenCalledWith({ url: 'https://air.example', allowInsecureTls: true }); + expect(toast.success).toHaveBeenCalledWith('settings.airtrail.test.success:{"count":3}'); + vi.mocked(airtrailApi.test).mockResolvedValueOnce({ connected: false, error: 'nope' }); + await act(() => result.current.handleTest()); + expect(result.current.connected).toBe(false); + expect(toast.error).toHaveBeenLastCalledWith('nope'); + }); + + it('FE-COMP-AIRTRAIL-HOOK-008: a test that throws toasts the generic failure and clears testing', async () => { + vi.mocked(airtrailApi.test).mockRejectedValue(new Error('x')); + const { result } = await loaded(); + await act(() => result.current.handleTest()); + expect(toast.error).toHaveBeenCalledWith('settings.airtrail.test.failed'); + expect(result.current.testing).toBe(false); + }); +}); diff --git a/client/src/components/Settings/useAirTrailSettings.ts b/client/src/components/Settings/useAirTrailSettings.ts new file mode 100644 index 000000000..04089bc97 --- /dev/null +++ b/client/src/components/Settings/useAirTrailSettings.ts @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { airtrailApi } from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useToast } from '../shared/Toast'; + +/** + * The per-user AirTrail connection form behind both settings shells: the desktop + * section and its phone twin render their own markup over this. The key is never + * prefilled, so a blank field means "keep the stored key". Not to be confused with + * hooks/useAirtrailConnection, which only answers whether a trip may offer AirTrail. + */ +export function useAirTrailSettings() { + const { t } = useTranslation(); + const toast = useToast(); + + const [url, setUrl] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [allowInsecureTls, setAllowInsecureTls] = useState(false); + const [writeEnabled, setWriteEnabled] = useState(false); + const [connected, setConnected] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(false); + + useEffect(() => { + airtrailApi + .getSettings() + .then((d) => { + setUrl(d.url || ''); + setAllowInsecureTls(!!d.allowInsecureTls); + setWriteEnabled(!!d.writeEnabled); + setConnected(!!d.connected); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + // Send the key only when the user typed a new one, never prefilled, so a blank + // field means "keep the stored key". + const keyPayload = (): { apiKey?: string } => { + const k = apiKey.trim(); + return k ? { apiKey: k } : {}; + }; + + const handleSave = async () => { + setSaving(true); + try { + const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() }); + const status = await airtrailApi.status().catch(() => ({ connected: false })); + setConnected(!!status.connected); + setApiKey(''); + if (d?.warning) toast.warning(d.warning); + else toast.success(t('settings.airtrail.toast.saved')); + } catch (err) { + const reason = (err as { response?: { data?: { error?: string } } } | null)?.response?.data?.error; + toast.error(reason || t('settings.airtrail.toast.saveError')); + } finally { + setSaving(false); + } + }; + + const handleTest = async () => { + setTesting(true); + try { + const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() }); + setConnected(!!d.connected); + if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 })); + else toast.error(d.error || t('settings.airtrail.test.failed')); + } catch { + toast.error(t('settings.airtrail.test.failed')); + } finally { + setTesting(false); + } + }; + + const canSave = !!url.trim() && (connected || !!apiKey.trim()); + + return { + url, + setUrl, + apiKey, + setApiKey, + allowInsecureTls, + toggleInsecureTls: () => setAllowInsecureTls((v) => !v), + writeEnabled, + toggleWriteEnabled: () => setWriteEnabled((v) => !v), + connected, + loading, + saving, + testing, + canSave, + handleSave, + handleTest, + }; +} diff --git a/client/src/mobile/screens/settings/MAirTrailConnectionSection.tsx b/client/src/mobile/screens/settings/MAirTrailConnectionSection.tsx index 0c7dd7063..c54165e09 100644 --- a/client/src/mobile/screens/settings/MAirTrailConnectionSection.tsx +++ b/client/src/mobile/screens/settings/MAirTrailConnectionSection.tsx @@ -1,8 +1,7 @@ -import React, { useEffect, useState } from 'react' +import React from 'react' import { Plane, Save, RefreshCw } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import { airtrailApi } from '../../../api/client' +import { useAirTrailSettings } from '../../../components/Settings/useAirTrailSettings' import { MSetCard, MSetEyebrow, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MToggle from '../../components/MToggle' @@ -14,68 +13,24 @@ import MToggle from '../../components/MToggle' */ export default function MAirTrailConnectionSection(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const [url, setUrl] = useState('') - const [apiKey, setApiKey] = useState('') - const [allowInsecureTls, setAllowInsecureTls] = useState(false) - const [writeEnabled, setWriteEnabled] = useState(false) - const [connected, setConnected] = useState(false) - const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) - const [testing, setTesting] = useState(false) - - useEffect(() => { - airtrailApi - .getSettings() - .then(d => { - setUrl(d.url || '') - setAllowInsecureTls(!!d.allowInsecureTls) - setWriteEnabled(!!d.writeEnabled) - setConnected(!!d.connected) - }) - .catch(() => {}) - .finally(() => setLoading(false)) - }, []) - - // Send the key only when the user typed a new one — never prefilled, so a blank - // field means "keep the stored key". - const keyPayload = (): { apiKey?: string } => { - const k = apiKey.trim() - return k ? { apiKey: k } : {} - } - - const handleSave = async () => { - setSaving(true) - try { - const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() }) - const status = await airtrailApi.status().catch(() => ({ connected: false })) - setConnected(!!status.connected) - setApiKey('') - if (d?.warning) toast.warning(d.warning) - else toast.success(t('settings.airtrail.toast.saved')) - } catch (err: any) { - toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError')) - } finally { - setSaving(false) - } - } - - const handleTest = async () => { - setTesting(true) - try { - const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() }) - setConnected(!!d.connected) - if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 })) - else toast.error(d.error || t('settings.airtrail.test.failed')) - } catch { - toast.error(t('settings.airtrail.test.failed')) - } finally { - setTesting(false) - } - } - - const canSave = !!url.trim() && (connected || !!apiKey.trim()) + const { + url, + setUrl, + apiKey, + setApiKey, + allowInsecureTls, + toggleInsecureTls, + writeEnabled, + toggleWriteEnabled, + connected, + loading, + saving, + testing, + canSave, + handleSave, + handleTest, + } = useAirTrailSettings() return ( @@ -104,7 +59,7 @@ export default function MAirTrailConnectionSection(): React.ReactElement { trailing={ setAllowInsecureTls(v => !v)} + onChange={toggleInsecureTls} ariaLabel={t('settings.airtrail.allowInsecureTls')} /> } @@ -115,7 +70,7 @@ export default function MAirTrailConnectionSection(): React.ReactElement { trailing={ setWriteEnabled(v => !v)} + onChange={toggleWriteEnabled} ariaLabel={t('settings.airtrail.writeBack')} /> }