// @vitest-environment jsdom import { act, renderHook, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { messageForCode, useFinalizeProject, } from '../../src/hooks/useFinalizeProject'; const REQUEST = { apiKey: 'sk-test', baseUrl: 'https://api.anthropic.com', model: 'claude-opus-4-7', maxTokens: 8192, }; const GOOGLE_REQUEST = { protocol: 'google', apiKey: 'AIza-test', baseUrl: 'https://generativelanguage.googleapis.com', model: 'gemini-2.0-flash', maxTokens: 8192, }; const SUCCESS_BODY = { designMdPath: '/tmp/p1/DESIGN.md', bytesWritten: 4096, model: 'claude-opus-4-7', inputTokens: 100, outputTokens: 200, artifact: { name: 'deck.html', updatedAt: '2026-05-08T00:00:00Z' }, transcriptMessageCount: 12, designSystemId: 'alphatrace', }; afterEach(() => { vi.restoreAllMocks(); }); function jsonResponse(body: unknown, status = 200) { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }); } describe('useFinalizeProject', () => { it('POSTs the FinalizeAnthropicRequest body verbatim and returns the success response', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse(SUCCESS_BODY)); const { result } = renderHook(() => useFinalizeProject('p1')); let returned: unknown; await act(async () => { returned = await result.current.trigger(REQUEST); }); expect(returned).toEqual(SUCCESS_BODY); expect(result.current.status).toBe('success'); expect(result.current.result).toEqual(SUCCESS_BODY); expect(result.current.error).toBeNull(); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe('/api/projects/p1/finalize/anthropic'); const sent = JSON.parse((init as RequestInit).body as string); expect(sent).toEqual(REQUEST); expect((init as RequestInit).method).toBe('POST'); expect((init as RequestInit).headers).toMatchObject({ 'Content-Type': 'application/json', }); }); it('routes provider-aware requests to the matching finalize endpoint', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({ ...SUCCESS_BODY, model: 'gemini-2.0-flash', })); const { result } = renderHook(() => useFinalizeProject('p1')); await act(async () => { await result.current.trigger(GOOGLE_REQUEST as any); }); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe('/api/projects/p1/finalize/google'); expect(JSON.parse((init as RequestInit).body as string)).toEqual(GOOGLE_REQUEST); }); const ERROR_TABLE: Array<{ code: string; expected: string }> = [ { code: 'BAD_REQUEST', expected: 'Bad request — check the API key and model.' }, { code: 'UNAUTHORIZED', expected: 'API key was rejected. Check it in Settings.' }, { code: 'FORBIDDEN', expected: 'Access denied by the upstream API.' }, { code: 'RATE_LIMITED', expected: 'The selected provider rate-limited the request. Try again in a minute.' }, { code: 'UPSTREAM_UNAVAILABLE', expected: 'The selected provider API is unavailable right now.' }, { code: 'CONFLICT', expected: 'Another finalize is in progress for this project.' }, { code: 'PROJECT_NOT_FOUND', expected: 'Project not found.' }, { code: 'INTERNAL_ERROR', expected: 'Something went wrong while finalizing. Check the daemon logs.' }, ]; it.each(ERROR_TABLE)( 'maps daemon error code $code to the canonical user-facing message', async ({ code, expected }) => { vi.spyOn(globalThis, 'fetch').mockResolvedValue( jsonResponse({ error: { code, message: 'whatever' } }, 400), ); const { result } = renderHook(() => useFinalizeProject('p1')); await act(async () => { await result.current.trigger(REQUEST); }); expect(result.current.status).toBe('error'); expect(result.current.error?.code).toBe(code); expect(result.current.error?.message).toBe(expected); }, ); it('maps a network error (fetch rejection) to the catch-all toast string', async () => { vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch')); const { result } = renderHook(() => useFinalizeProject('p1')); await act(async () => { await result.current.trigger(REQUEST); }); expect(result.current.status).toBe('error'); expect(result.current.error?.code).toBe('NETWORK_ERROR'); expect(result.current.error?.message).toBe( "Couldn't reach the daemon. Make sure it's running.", ); }); it('renders body.error.details as a separate field on the error state when present (#450 commitment)', async () => { const usageCapDetails = 'You have reached your specified API usage limits. You will regain access on 2026-06-01 at 00:00 UTC.'; vi.spyOn(globalThis, 'fetch').mockResolvedValue( jsonResponse( { error: { code: 'UPSTREAM_UNAVAILABLE', message: 'upstream', details: usageCapDetails, }, }, 400, ), ); const { result } = renderHook(() => useFinalizeProject('p1')); await act(async () => { await result.current.trigger(REQUEST); }); expect(result.current.status).toBe('error'); expect(result.current.error?.code).toBe('UPSTREAM_UNAVAILABLE'); expect(result.current.error?.message).toBe('The selected provider API is unavailable right now.'); expect(result.current.error?.details).toBe(usageCapDetails); }); it('cancels the in-flight request and returns to idle without an error surface', async () => { let abortFromHandler: AbortSignal | undefined; const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { abortFromHandler = (init as RequestInit | undefined)?.signal as AbortSignal | undefined; return new Promise((_resolve, reject) => { abortFromHandler?.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }); }); const { result } = renderHook(() => useFinalizeProject('p1')); let triggerPromise!: Promise; act(() => { triggerPromise = result.current.trigger(REQUEST); }); await waitFor(() => expect(result.current.status).toBe('pending')); act(() => { result.current.cancel(); }); await act(async () => { await triggerPromise; }); expect(fetchSpy).toHaveBeenCalledTimes(1); expect(abortFromHandler?.aborted).toBe(true); expect(result.current.status).toBe('idle'); expect(result.current.error).toBeNull(); }); it('does not let a superseded trigger leak idle state into a replacement that is still pending', async () => { // First trigger never resolves on its own — only the cancel from // trigger 2 will end it. Second trigger also stays pending so we // can observe that trigger 1's late AbortError catch did NOT // reset status to 'idle' under us. const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { const sig = (init as RequestInit | undefined)?.signal as AbortSignal | undefined; return new Promise((_resolve, reject) => { sig?.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }); }); const { result } = renderHook(() => useFinalizeProject('p1')); let firstTrigger!: Promise; let secondTrigger!: Promise; act(() => { firstTrigger = result.current.trigger(REQUEST); }); await waitFor(() => expect(result.current.status).toBe('pending')); act(() => { secondTrigger = result.current.trigger(REQUEST); }); // Trigger 2 swapped abortRef + aborted trigger 1's controller. // Wait for trigger 1's rejection to be flushed through the catch. await act(async () => { await firstTrigger; }); // The replacement is still in flight — status MUST stay // 'pending'. If trigger 1's late catch had leaked through, status // would have flipped to 'idle' here. expect(result.current.status).toBe('pending'); expect(result.current.error).toBeNull(); expect(fetchSpy).toHaveBeenCalledTimes(2); // Cleanup: abort trigger 2 so the never-resolving promise is // settled before the test exits. act(() => { result.current.cancel(); }); await act(async () => { await secondTrigger; }); }); it('surfaces a TIMEOUT error when the 130 s timeout aborts the in-flight request', async () => { vi.useFakeTimers(); try { let abortFromHandler: AbortSignal | undefined; vi.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { abortFromHandler = (init as RequestInit | undefined)?.signal as AbortSignal | undefined; return new Promise((_resolve, reject) => { abortFromHandler?.addEventListener('abort', () => { const err = new Error('aborted'); err.name = 'AbortError'; reject(err); }); }); }); const { result } = renderHook(() => useFinalizeProject('p1')); let triggerPromise!: Promise; act(() => { triggerPromise = result.current.trigger(REQUEST); }); // Advance past the 130 s internal timeout — its callback flips // timedOutRef and aborts the controller, so the catch should // surface a TIMEOUT error rather than the user-cancel idle reset. await act(async () => { await vi.advanceTimersByTimeAsync(130_001); await triggerPromise; }); expect(abortFromHandler?.aborted).toBe(true); expect(result.current.status).toBe('error'); expect(result.current.error?.code).toBe('TIMEOUT'); expect(result.current.error?.message).toBe( 'Finalize timed out after 130 s. The daemon may still be running.', ); } finally { vi.useRealTimers(); } }); }); describe('messageForCode', () => { it('returns the network-error catch-all for unknown codes', () => { expect(messageForCode('SOME_NEW_CODE_THE_DAEMON_WILL_ADD')).toBe( "Couldn't reach the daemon. Make sure it's running.", ); }); });