/** @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 fixed dev', (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('rejects release Tauri requests outside the fixed dev API origin', () => { expect(() => resolveClientHttpTarget('https://example.com/api/auth/me', { isTauri: true, mode: 'production', }), ).toThrow('固定的 dev 服务范围'); }); it.each(['release', 'dev', 'custom'])( 'ignores persisted %s preference when resolving web requests', (preset) => { window.localStorage.setItem( 'genarrative.client.server-selection.v1', JSON.stringify({ preset, customBaseUrl: 'https://staging.example.com', }), ); vi.stubEnv('DEV', false); expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL); expect( resolveClientHttpTarget('/api/auth/me', { isTauri: false, mode: 'development', }), ).toEqual({ transport: 'web', url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, }); }, ); it.each(['development', 'production', 'test'])( 'rejects explicit origin overrides before transport in %s', async (mode) => { vi.stubEnv('MODE', mode); const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); await expect( fetchClientHttp( '/api/auth/me', {}, { serverBaseUrl: 'https://www.genarrative.world', }, ), ).rejects.toThrow('固定的 dev 服务范围'); expect(fetchMock).not.toHaveBeenCalled(); 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(); }); });