/** @vitest-environment jsdom */ import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION, } from '../../../packages/shared/src/http'; import { AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE, AGC_DEVELOPMENT_API_BASE_URL, ClientHttpTimeoutError, fetchClientHttp, getClientServerBaseUrl, readClientHttpResponseText, resolveClientHttpTarget, } from '../src/services/clientHttp'; vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn(), })); describe('AGC client HTTP transport', () => { afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); window.localStorage.clear(); }); it('adds the AGC marker while preserving and overriding request headers', async () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 204 })); vi.stubGlobal('fetch', fetchMock); const inputHeaders = new Headers({ Authorization: 'Bearer fixture-token', 'X-Request-ID': 'request-123', [API_RESPONSE_ENVELOPE_HEADER]: API_RESPONSE_ENVELOPE_VERSION, [AGC_CLIENT_MARKER_HEADER]: 'caller-value', }); const init: RequestInit = { method: 'POST', headers: inputHeaders, body: '{}', credentials: 'same-origin', }; await fetchClientHttp('/api/auth/me', init); expect(fetchMock).toHaveBeenCalledTimes(1); const [target, forwardedInit] = fetchMock.mock.calls[0] as [ string, RequestInit, ]; const forwardedHeaders = new Headers(forwardedInit.headers); expect(target).toBe('/api/auth/me'); expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( AGC_CLIENT_MARKER_VALUE, ); expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); expect(forwardedHeaders.get('X-Request-ID')).toBe('request-123'); expect(forwardedHeaders.get(API_RESPONSE_ENVELOPE_HEADER)).toBe( API_RESPONSE_ENVELOPE_VERSION, ); expect(forwardedInit.method).toBe('POST'); expect(forwardedInit.body).toBe('{}'); expect(forwardedInit.credentials).toBe('same-origin'); expect(inputHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe('caller-value'); }); it.each([ '/api/auth/me', '/api/profile/dashboard', '/api/editor/projects', '/api/assets/read-bytes?objectKey=fixture', ])('marks %s through the shared Web transport', async (url) => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 204 })); vi.stubGlobal('fetch', fetchMock); await fetchClientHttp(url, {}); const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; expect( new Headers(forwardedInit.headers).get(AGC_CLIENT_MARKER_HEADER), ).toBe(AGC_CLIENT_MARKER_VALUE); }); it('adds the AGC marker to the Tauri HTTP transport', async () => { const tauriFetchMock = vi.mocked(tauriHttpFetch); tauriFetchMock.mockResolvedValue(new Response(null, { status: 204 })); vi.stubEnv('MODE', 'production'); vi.stubEnv('DEV', false); vi.stubGlobal('window', { __TAURI__: {}, location: { protocol: 'tauri:' }, }); await fetchClientHttp( '/api/auth/me', { headers: { Authorization: 'Bearer fixture-token' } }, { serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL }, ); expect(tauriFetchMock).toHaveBeenCalledTimes(1); const [target, forwardedInit] = tauriFetchMock.mock.calls[0] as [ string, RequestInit, ]; const forwardedHeaders = new Headers(forwardedInit.headers); expect(target).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`); expect(forwardedHeaders.get(AGC_CLIENT_MARKER_HEADER)).toBe( AGC_CLIENT_MARKER_VALUE, ); expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); }); it.each(['development', 'production'])( 'routes %s Tauri requests through the active channel server', (mode) => { expect( resolveClientHttpTarget('/api/auth/me', { isTauri: true, mode, }), ).toEqual({ transport: 'tauri-http', url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, }); }, ); it('keeps test fixtures on relative requests after origin validation', () => { expect( resolveClientHttpTarget('/api/auth/me', { isTauri: false, mode: 'test', }), ).toEqual({ transport: 'web', url: '/api/auth/me' }); }); it('routes debug requests through an explicit custom server', () => { expect( resolveClientHttpTarget('https://example.com/api/auth/me', { isTauri: true, mode: 'development', serverBaseUrl: 'https://example.com', }), ).toEqual({ transport: 'tauri-http', url: 'https://example.com/api/auth/me', }); }); it.each(['release', 'dev', 'custom'])( 'honors persisted %s preference in debug requests', (preset) => { window.localStorage.setItem( 'genarrative.client.server-selection.v1', JSON.stringify({ preset, customBaseUrl: 'https://staging.example.com', }), ); expect(getClientServerBaseUrl()).toBe( preset === 'release' ? 'https://www.genarrative.world' : preset === 'custom' ? 'https://staging.example.com' : AGC_DEVELOPMENT_API_BASE_URL, ); expect( resolveClientHttpTarget('/api/auth/me', { isTauri: false, mode: 'development', }), ).toEqual({ transport: 'web', url: `${getClientServerBaseUrl()}/api/auth/me`, }); }, ); it('keeps explicit server origin when sending a debug request', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); fetchMock.mockResolvedValue(new Response(null, { status: 204 })); await fetchClientHttp( '/api/auth/me', {}, { serverBaseUrl: 'https://www.genarrative.world', }, ); expect(fetchMock).toHaveBeenCalledWith('/api/auth/me', expect.any(Object)); expect(tauriHttpFetch).not.toHaveBeenCalled(); }); it('aborts a stalled Web request at the configured timeout', async () => { vi.useFakeTimers(); const fetchMock = vi.fn(() => new Promise(() => {})); vi.stubGlobal('fetch', fetchMock); const request = fetchClientHttp('/api/auth/me', {}, { timeoutMs: 25 }); const timeoutAssertion = expect(request).rejects.toMatchObject({ name: 'ClientHttpTimeoutError', code: 'CLIENT_HTTP_TIMEOUT', timeoutMs: 25, url: '/api/auth/me', }); await vi.advanceTimersByTimeAsync(25); await timeoutAssertion; expect(fetchMock).toHaveBeenCalledTimes(1); const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(forwardedInit.signal).toBeInstanceOf(AbortSignal); expect((forwardedInit.signal as AbortSignal).aborted).toBe(true); }); it('times out after response headers when the response body never completes', async () => { vi.useFakeTimers(); const response = new Response( new ReadableStream({ start() { // Keep the stream open forever: headers exist, body does not finish. }, }), ); const read = readClientHttpResponseText(response, { timeoutMs: 25, url: '/api/auth/refresh', }); const assertion = expect(read).rejects.toMatchObject({ name: 'ClientHttpTimeoutError', code: 'CLIENT_HTTP_TIMEOUT', timeoutMs: 25, url: '/api/auth/refresh', }); await vi.advanceTimersByTimeAsync(25); await assertion; }); it('preserves caller AbortError and does not report it as a timeout', async () => { const fetchMock = vi.fn( (_url: string, init: RequestInit) => new Promise((_, reject) => { init.signal?.addEventListener('abort', () => { reject( new DOMException('The operation was aborted.', 'AbortError'), ); }); }), ); vi.stubGlobal('fetch', fetchMock); const callerController = new AbortController(); const request = fetchClientHttp( '/api/auth/me', { signal: callerController.signal }, { timeoutMs: 10_000 }, ); callerController.abort(); await expect(request).rejects.toMatchObject({ name: 'AbortError' }); await expect(request).rejects.not.toBeInstanceOf(ClientHttpTimeoutError); }); it('allows explicitly disabling the timeout for long-running requests', async () => { const fetchMock = vi .fn() .mockResolvedValue(new Response(null, { status: 204 })); vi.stubGlobal('fetch', fetchMock); await expect( fetchClientHttp('/api/agent/run', {}, { timeoutMs: null }), ).resolves.toBeInstanceOf(Response); const [, forwardedInit] = fetchMock.mock.calls[0] as [string, RequestInit]; expect(forwardedInit.signal).toBeUndefined(); }); });