diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx index 9a9ed115..ba8b2372 100644 --- a/src/app/setup/page.tsx +++ b/src/app/setup/page.tsx @@ -5,6 +5,7 @@ import Image from 'next/image' import { useTranslations } from 'next-intl' import { Button } from '@/components/ui/button' import { LanguageSwitcherSelect } from '@/components/ui/language-switcher' +import { fetchSetupStatusWithRetry } from '@/lib/setup-status' type SetupStep = 'form' | 'creating' @@ -13,6 +14,9 @@ interface ProgressStep { status: 'pending' | 'active' | 'done' | 'error' } +const SETUP_STATUS_TIMEOUT_MS = 5000 +const SETUP_STATUS_ATTEMPTS = 3 + function getInitialProgress(t: (key: string) => string): ProgressStep[] { return [ { label: t('validatingCredentials'), status: 'pending' }, @@ -72,24 +76,36 @@ export default function SetupPage() { const [progress, setProgress] = useState(() => getInitialProgress(t)) const [checking, setChecking] = useState(true) const [setupAvailable, setSetupAvailable] = useState(false) + const [setupCheckTick, setSetupCheckTick] = useState(0) - useEffect(() => { - fetch('/api/setup') - .then((res) => res.json()) - .then((data) => { - if (!data.needsSetup) { - window.location.href = '/login' - return - } - setSetupAvailable(true) - setChecking(false) - }) - .catch(() => { - setError(t('failedToCheckSetup')) - setChecking(false) + const checkSetupStatus = useCallback(async () => { + setChecking(true) + setError('') + + try { + const data = await fetchSetupStatusWithRetry(fetch, { + attempts: SETUP_STATUS_ATTEMPTS, + timeoutMs: SETUP_STATUS_TIMEOUT_MS, }) + + if (!data.needsSetup) { + window.location.href = '/login' + return + } + + setSetupAvailable(true) + setChecking(false) + } catch { + setSetupAvailable(false) + setError(t('failedToCheckSetup')) + setChecking(false) + } }, [t]) + useEffect(() => { + checkSetupStatus() + }, [checkSetupStatus, setupCheckTick]) + const updateProgress = useCallback((index: number, status: ProgressStep['status']) => { setProgress((prev) => prev.map((s, i) => (i === index ? { ...s, status } : s))) }, []) @@ -174,7 +190,21 @@ export default function SetupPage() { } if (!setupAvailable) { - return null + return ( +
+
+

{error || t('failedToCheckSetup')}

+ +
+
+ ) } return ( diff --git a/src/lib/__tests__/setup-status.test.ts b/src/lib/__tests__/setup-status.test.ts new file mode 100644 index 00000000..c24e3afb --- /dev/null +++ b/src/lib/__tests__/setup-status.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest' +import { fetchSetupStatusWithRetry } from '@/lib/setup-status' + +describe('fetchSetupStatusWithRetry', () => { + it('returns setup status on success', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ needsSetup: true }), + }) + + const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 }) + + expect(result).toEqual({ needsSetup: true }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('retries on failure and eventually succeeds', async () => { + const fetchMock = vi.fn() + .mockRejectedValueOnce(new Error('network down')) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ needsSetup: false }), + }) + + const result = await fetchSetupStatusWithRetry(fetchMock as any, { attempts: 3, timeoutMs: 2000 }) + + expect(result).toEqual({ needsSetup: false }) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws after attempts are exhausted', async () => { + const fetchMock = vi.fn().mockRejectedValue(new Error('still failing')) + + await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 2, timeoutMs: 2000 })) + .rejects.toThrow('still failing') + + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('throws on invalid payload shape', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ needsSetup: 'yes' }), + }) + + await expect(fetchSetupStatusWithRetry(fetchMock as any, { attempts: 1, timeoutMs: 2000 })) + .rejects.toThrow('Invalid setup status response') + }) +}) diff --git a/src/lib/setup-status.ts b/src/lib/setup-status.ts new file mode 100644 index 00000000..2866ffa8 --- /dev/null +++ b/src/lib/setup-status.ts @@ -0,0 +1,53 @@ +export interface FetchSetupStatusOptions { + attempts?: number + timeoutMs?: number +} + +export interface SetupStatusResponse { + needsSetup: boolean +} + +/** + * Fetch setup status with timeout + bounded retries. + */ +export async function fetchSetupStatusWithRetry( + fetchFn: typeof fetch, + options: FetchSetupStatusOptions = {} +): Promise { + const attempts = Math.max(1, options.attempts ?? 3) + const timeoutMs = Math.max(1000, options.timeoutMs ?? 5000) + + let lastError: Error | null = null + + for (let attempt = 1; attempt <= attempts; attempt++) { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort('timeout'), timeoutMs) + + try { + const res = await fetchFn('/api/setup', { signal: controller.signal }) + if (!res.ok) { + throw new Error(`Setup status check failed (${res.status})`) + } + const data = await res.json() as SetupStatusResponse + if (typeof data?.needsSetup !== 'boolean') { + throw new Error('Invalid setup status response') + } + return data + } catch (error: any) { + if (error?.name === 'AbortError') { + lastError = new Error('Setup status request timed out') + } else { + lastError = error instanceof Error ? error : new Error('Setup status request failed') + } + + if (attempt < attempts) { + // tiny backoff to avoid immediate hammering during transient stalls + await new Promise(resolve => setTimeout(resolve, 250 * attempt)) + } + } finally { + clearTimeout(timeoutId) + } + } + + throw (lastError ?? new Error('Failed to check setup status')) +}