import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { UpdateReadinessDialog } from '../UpdateReadinessDialog'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); const authState = { isAdmin: true }; vi.mock('@/context/AuthContext', () => ({ useAuth: () => authState, })); import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; type Verdict = 'ready' | 'ready_with_warnings' | 'review_required' | 'blocked' | 'unknown'; const report = (verdict: Verdict) => ({ stack: 'web', computedAt: Date.now(), verdict, signals: [ { id: 'preflight', status: 'ok', title: 'Compose Doctor', detail: 'The last preflight passed.', affectsVerdict: true }, ], }); function routeApi(over: { readiness?: () => Promise; coverage?: () => Promise; snapshot?: () => Promise; } = {}) { vi.mocked(apiFetch).mockImplementation((url: string, options?: { method?: string }) => { const u = String(url); if (u.includes('/update-readiness')) { return (over.readiness ?? (() => Promise.resolve(new Response(JSON.stringify(report('ready')), { status: 200 }))))(); } if (u.includes('/snapshots/coverage')) { return (over.coverage ?? (() => Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 }))))(); } if (u.includes('/fleet/snapshots') && options?.method === 'POST') { return (over.snapshot ?? (() => Promise.resolve(new Response('{}', { status: 200 }))))(); } return Promise.resolve(new Response('{}', { status: 200 })); }); } function setup(props: Partial[0]> = {}) { const base = { open: true, stackName: 'web', nodeId: 1, onCancel: vi.fn(), onProceed: vi.fn(), ...props, }; render(); return base; } describe('UpdateReadinessDialog', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); vi.clearAllMocks(); authState.isAdmin = true; }); const verdictCases: Array<{ verdict: Verdict; label: string }> = [ { verdict: 'ready', label: 'ready' }, { verdict: 'ready_with_warnings', label: 'ready with warnings' }, { verdict: 'review_required', label: 'review required' }, { verdict: 'blocked', label: 'blocked' }, { verdict: 'unknown', label: 'unknown' }, ]; it.each(verdictCases)('renders the $verdict verdict with Proceed enabled', async ({ verdict, label }) => { routeApi({ readiness: () => Promise.resolve(new Response(JSON.stringify(report(verdict)), { status: 200 })) }); setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', verdict)); expect(screen.getByText(label)).toBeInTheDocument(); expect(screen.getByTestId('readiness-proceed')).toBeEnabled(); }); it('fetches readiness and coverage against the captured node, not the active node', async () => { routeApi(); setup({ nodeId: 7 }); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); const readinessCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/update-readiness')); expect(readinessCall).toBeDefined(); expect((readinessCall![1] as { nodeId?: number | null } | undefined)?.nodeId).toBe(7); const coverageCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/snapshots/coverage')); expect(String(coverageCall?.[0])).toContain('nodeId=7'); }); it('degrades to unknown on a plain network failure, and stays non-blocking', async () => { routeApi({ readiness: () => Promise.reject(new Error('connection refused')) }); const props = setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown')); expect(screen.getByText(/could not be reached/)).toBeInTheDocument(); fireEvent.click(screen.getByTestId('readiness-proceed')); await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1)); }); it('reports a timed-out readiness check as unknown after the 4s timer aborts it', async () => { vi.useFakeTimers(); try { vi.mocked(apiFetch).mockImplementation((url: string, options?: RequestInit) => { if (String(url).includes('/update-readiness')) { return new Promise((_, reject) => { options?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); }); } return Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 })); }); render(); await act(async () => { await vi.advanceTimersByTimeAsync(4_100); }); expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown'); expect(screen.getByText(/did not respond in time/)).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); it('does not paint a stale fallback verdict after closing mid-fetch and reopening', async () => { let call = 0; vi.mocked(apiFetch).mockImplementation((url: string, options?: RequestInit) => { const u = String(url); if (u.includes('/update-readiness')) { call += 1; if (call === 1) { // Hangs until the cleanup abort rejects it. return new Promise((_, reject) => { options?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); }); } return Promise.resolve(new Response(JSON.stringify(report('ready')), { status: 200 })); } return Promise.resolve(new Response(JSON.stringify({ latestAt: null }), { status: 200 })); }); const props = { stackName: 'web', nodeId: 1, onCancel: vi.fn(), onProceed: vi.fn() }; const { rerender } = render(); rerender(); rerender(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'ready')); }); it('marks a remote 502 as unreachable and unknown', async () => { routeApi({ readiness: () => Promise.resolve(new Response('Bad Gateway', { status: 502 })) }); setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toHaveAttribute('data-verdict', 'unknown')); expect(screen.getByText(/may be unreachable/)).toBeInTheDocument(); }); it('invokes onProceed without a snapshot when the checkbox is unchecked', async () => { routeApi(); const props = setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); fireEvent.click(screen.getByTestId('readiness-proceed')); await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1)); const snapshotPosts = vi.mocked(apiFetch).mock.calls.filter( c => String(c[0]) === '/fleet/snapshots' && (c[1] as { method?: string } | undefined)?.method === 'POST', ); expect(snapshotPosts).toHaveLength(0); }); it('creates a snapshot before proceeding when checked', async () => { routeApi(); const props = setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); fireEvent.click(screen.getByLabelText('Create a fleet snapshot before updating')); fireEvent.click(screen.getByTestId('readiness-proceed')); await waitFor(() => expect(props.onProceed).toHaveBeenCalledTimes(1)); const snapshotPosts = vi.mocked(apiFetch).mock.calls.filter( c => String(c[0]) === '/fleet/snapshots' && (c[1] as { method?: string } | undefined)?.method === 'POST', ); expect(snapshotPosts).toHaveLength(1); }); it('halts the update when the pre-update snapshot fails', async () => { routeApi({ snapshot: () => Promise.resolve(new Response('{"error":"boom"}', { status: 500 })) }); const props = setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); fireEvent.click(screen.getByLabelText('Create a fleet snapshot before updating')); fireEvent.click(screen.getByTestId('readiness-proceed')); await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(props.onProceed).not.toHaveBeenCalled(); }); it('hides the snapshot checkbox and coverage row from non-admins', async () => { authState.isAdmin = false; routeApi(); setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); expect(screen.queryByLabelText('Create a fleet snapshot before updating')).not.toBeInTheDocument(); expect(screen.queryByText('Fleet snapshot')).not.toBeInTheDocument(); const coverageCalls = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/snapshots/coverage')); expect(coverageCalls).toHaveLength(0); }); it('shows the hub snapshot coverage row for admins', async () => { routeApi({ coverage: () => Promise.resolve(new Response(JSON.stringify({ latestAt: Date.now() - 60_000 }), { status: 200 })) }); setup(); await waitFor(() => expect(screen.getByText('Fleet snapshot')).toBeInTheDocument()); expect(screen.getByText(/most recent fleet snapshot/)).toBeInTheDocument(); }); it('wires Cancel', async () => { routeApi(); const props = setup(); await waitFor(() => expect(screen.getByTestId('readiness-verdict')).toBeInTheDocument()); fireEvent.click(screen.getByText('Cancel')); expect(props.onCancel).toHaveBeenCalledTimes(1); }); });