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, AGC_RELEASE_API_BASE_URL, ClientHttpTimeoutError, fetchClientHttp, getClientServerBaseUrl, getClientServerSelection, normalizeClientServerBaseUrl, resetClientServerSelectionForTests, resolveClientHttpTarget, setClientServerSelection, } 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(); resetClientServerSelectionForTests(); }); 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('keeps local development requests on the Vite API proxy', () => { expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: true, isTauri: true, pageProtocol: 'http:', }), ).toEqual({ transport: 'web', url: '/api/auth/me' }); }); it('routes release Tauri requests through the scoped dev API transport', () => { expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: false, isTauri: true, pageProtocol: 'tauri:', mode: 'production', serverBaseUrl: AGC_RELEASE_API_BASE_URL, }), ).toEqual({ transport: 'tauri-http', url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`, }); }); it('keeps ordinary web releases on same-origin relative requests', () => { expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: false, isTauri: false, pageProtocol: 'https:', 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', { isDevelopment: false, isTauri: true, pageProtocol: 'tauri:', mode: 'production', serverBaseUrl: AGC_RELEASE_API_BASE_URL, }), ).toThrow('当前选择的服务器范围'); }); it('persists release, dev, and custom server selection', () => { const release = setClientServerSelection({ preset: 'release', customBaseUrl: '', }); expect(release).toEqual({ preset: 'release', customBaseUrl: '', }); expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL); const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL); const custom = setClientServerSelection({ preset: 'custom', customBaseUrl: 'https://staging.example.com/', }); expect(custom).toEqual({ preset: 'custom', customBaseUrl: 'https://staging.example.com', }); expect(getClientServerSelection().preset).toBe('dev'); expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com'); }); it('accepts HTTPS custom servers and loopback HTTP only', () => { expect(normalizeClientServerBaseUrl('https://example.com/')).toBe( 'https://example.com', ); expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe( 'http://127.0.0.1:8080', ); expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow( '必须使用 HTTPS', ); expect(() => normalizeClientServerBaseUrl('https://example.com/api'), ).toThrow('纯 HTTP(S)'); }); it('routes selected custom servers for both web and Tauri clients', () => { const serverBaseUrl = 'https://staging.example.com'; expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: true, isTauri: false, pageProtocol: 'http:', mode: 'development', serverBaseUrl, }), ).toEqual({ transport: 'web', url: `${serverBaseUrl}/api/auth/me`, }); expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: false, isTauri: true, pageProtocol: 'tauri:', mode: 'production', serverBaseUrl, }), ).toEqual({ transport: 'tauri-http', url: `${serverBaseUrl}/api/auth/me`, }); }); it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => { expect( resolveClientHttpTarget('/api/auth/me', { isDevelopment: false, isTauri: true, pageProtocol: 'http:', mode: 'production', serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL, }), ).toEqual({ transport: 'tauri-http', url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`, }); }); 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('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(); }); });