// @vitest-environment node import { describe, expect, it, vi } from 'vitest'; import { WebSearchClient, WebSearchClientError, } from '../../electron/services/web-search-client'; const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; const ADMISSION_ID = 'admission-a'; const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; const LOGICAL_OPERATION_ID = 'pi:run-a:resource-a'; const MAX_JSON_BYTES = 1_048_576; function search(overrides: Record = {}) { return { schema_version: 1, plugin_id: 'makelore.web-search', execution_id: '11111111-1111-4111-8111-111111111111', release_id: RELEASE_ID, logical_operation_id: LOGICAL_OPERATION_ID, status: 'succeeded', answer: 'The answer', sources: [{ title: 'Primary source', url: 'https://example.test/source' }], search_queries: ['latest release'], error_code: null, retry_after_seconds: null, billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', usage_amount: 1, unit: 'search_request', }, ...overrides, }; } function jsonResponse(value: unknown, status = 200, headers: Record = {}): Response { return new Response(JSON.stringify(value), { status, headers: { 'content-type': 'application/json', ...headers }, }); } function input(query = 'latest release') { return { releaseId: RELEASE_ID, releaseAdmissionId: ADMISSION_ID, projectId: PROJECT_ID, logicalOperationId: LOGICAL_OPERATION_ID, query, confirmed: true as const, }; } describe('WebSearchClient', () => { it('posts the closed request, refreshes one 401, and projects the server receipt', async () => { const fetchImpl = vi.fn() .mockResolvedValueOnce(new Response(null, { status: 401 })) .mockResolvedValueOnce(jsonResponse(search(), 200)); const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => ( options?.forceRefresh ? 'fresh-token' : 'stale-token' )); const client = new WebSearchClient({ apiBaseUrl: 'https://works.example/', fetchImpl, getAccessToken: getAccessToken as never, }); await expect(client.search(input())).resolves.toEqual({ executionId: '11111111-1111-4111-8111-111111111111', releaseId: RELEASE_ID, logicalOperationId: LOGICAL_OPERATION_ID, status: 'succeeded', answer: 'The answer', sources: [{ title: 'Primary source', url: 'https://example.test/source' }], searchQueries: ['latest release'], errorCode: null, billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', usage_amount: 1, unit: 'search_request', }, }); expect(fetchImpl).toHaveBeenCalledTimes(2); expect(fetchImpl.mock.calls[0]?.[0]).toBe( 'https://works.example/api/plugins/v1/hosted/web-search/searches', ); expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', redirect: 'manual', headers: { Authorization: 'Bearer stale-token', 'Content-Type': 'application/json' }, }); expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual({ release_id: RELEASE_ID, release_admission_id: ADMISSION_ID, project_id: PROJECT_ID, logical_operation_id: LOGICAL_OPERATION_ID, query: 'latest release', confirmed: true, }); expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({ headers: { Authorization: 'Bearer fresh-token' }, }); expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); }); it('rejects oversized input and unknown response fields without a request', async () => { const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(search({ extra: true }))); const client = new WebSearchClient({ fetchImpl, getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input('x'.repeat(2_001)))).rejects.toMatchObject({ code: 'plugin_input_invalid', status: 422, retryable: false, }); expect(fetchImpl).not.toHaveBeenCalled(); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_backend_invalid', status: 502, retryable: false, }); }); it('matches the frozen server source title and URL bounds exactly', async () => { const maximumUrl = `https://example.test/${'x'.repeat(2_048 - 'https://example.test/'.length)}`; await expect(new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ sources: [{ title: 't'.repeat(240), url: maximumUrl }], }))), getAccessToken: vi.fn(async () => 'token') as never, }).search(input())).resolves.toMatchObject({ sources: [{ title: 't'.repeat(240), url: maximumUrl }], }); await expect(new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }], }))), getAccessToken: vi.fn(async () => 'token') as never, }).search(input())).rejects.toMatchObject({ code: 'plugin_backend_invalid', status: 502, retryable: false, }); }); it.each([ 'http://user:password@example.test/source', 'javascript:alert(1)', '//example.test/source', 'https:example.test/source', 'https://example.test/' + 'x'.repeat(2_049 - 'https://example.test/'.length), ])('rejects an unsafe or overlong source URL: %s', async (url) => { const client = new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ sources: [{ title: 'Source', url }], }))), getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_backend_invalid', status: 502, retryable: false, }); }); it('preserves only a bounded Retry-After for a known rate limit', async () => { const fetchImpl = vi.fn().mockResolvedValue(jsonResponse( { detail: { error_code: 'web_search_rate_limited', message: 'try later' } }, 429, { 'retry-after': '30' }, )); const client = new WebSearchClient({ fetchImpl, getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'web_search_rate_limited', status: 429, retryable: false, retryAfterSeconds: 30, }); }); it('uses HTTP 429 as the authoritative non-retryable rate-limit result', async () => { const responses = [ new Response('{malformed', { status: 429, headers: { 'retry-after': '31' }, }), new Response('x'.repeat(MAX_JSON_BYTES + 1), { status: 429, headers: { 'retry-after': '31' }, }), jsonResponse( { detail: { error_code: 'plugin_provider_unavailable', message: 'private' } }, 429, { 'retry-after': '31' }, ), ]; for (const response of responses) { const client = new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(response), getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'web_search_rate_limited', status: 429, retryable: false, retryAfterSeconds: 31, }); } }); it('projects a server-side rate-limit result and does not trust Retry-After on other statuses', async () => { const rateLimited = vi.fn().mockResolvedValue(jsonResponse(search({ status: 'failed', answer: null, sources: [], search_queries: [], error_code: 'web_search_rate_limited', retry_after_seconds: 30, billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' }, }))); const client = new WebSearchClient({ fetchImpl: rateLimited, getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).resolves.toMatchObject({ status: 'failed', errorCode: 'web_search_rate_limited', retryAfterSeconds: 30, billing: { status: 'released' }, }); const unavailable = vi.fn().mockResolvedValue(jsonResponse( { detail: { error_code: 'plugin_provider_unavailable', message: 'private provider text' } }, 503, { 'retry-after': '30' }, )); const unavailableClient = new WebSearchClient({ fetchImpl: unavailable, getAccessToken: vi.fn(async () => 'token') as never, }); await expect(unavailableClient.search(input())).rejects.toMatchObject({ code: 'plugin_provider_unavailable', status: 503, retryable: true, retryAfterSeconds: undefined, }); }); it('fails closed on a result-bearing status with a mismatched HTTP status', async () => { const client = new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search(), 202)), getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_backend_invalid', status: 502, retryable: false, }); }); it('bounds a chunked response before buffering the complete body', async () => { let cancelled = false; let chunks = 0; const stream = new ReadableStream({ pull(controller) { controller.enqueue(new Uint8Array(chunks++ === 0 ? MAX_JSON_BYTES : 1)); }, cancel() { cancelled = true; }, }); const response = new Response(stream, { status: 200 }); const arrayBuffer = vi.spyOn(response, 'arrayBuffer'); const client = new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(response), getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_backend_response_too_large', status: 502, retryable: false, }); expect(arrayBuffer).not.toHaveBeenCalled(); expect(cancelled).toBe(true); }); it('rejects incoherent search, billing, and error pairings', async () => { const invalidPayloads = [ search({ billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1', usage_amount: 1, unit: 'search_request', }, }), search({ billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.000', usage_amount: 1, unit: 'search_request', }, }), search({ billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', usage_amount: 2, unit: 'search_request', }, }), search({ billing: { mode: 'platform_metered', status: 'settled', reserved_points: '1.00', actual_points: '1.00', unit: 'search_request', }, }), search({ sources: [] }), search({ status: 'pending_review', billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request', }, sources: [], }), search({ billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request', }, }), search({ status: 'failed', answer: null, sources: [], search_queries: [], error_code: null, billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', usage_amount: 1, unit: 'search_request', }, }), search({ status: 'submission_unknown', answer: null, sources: [], search_queries: [], error_code: 'web_search_rate_limited', billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request', }, }), ]; for (const payload of invalidPayloads) { const client = new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(payload)), getAccessToken: vi.fn(async () => 'token') as never, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_backend_invalid', status: 502, retryable: false, }); } }); it('accepts omitted nullable/default response fields while keeping the object closed', async () => { const payload = search(); delete payload.answer; delete payload.sources; delete payload.search_queries; delete payload.error_code; delete payload.retry_after_seconds; payload.status = 'failed'; payload.error_code = 'web_search_request_invalid'; payload.billing = { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request', }; await expect(new WebSearchClient({ fetchImpl: vi.fn().mockResolvedValue(jsonResponse(payload)), getAccessToken: vi.fn(async () => 'token') as never, }).search(input())).resolves.toMatchObject({ status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: 'web_search_request_invalid', }); }); it('reconciles the same operation after in-progress and transport responses', async () => { let clock = 0; const fetchImpl = vi.fn() .mockResolvedValueOnce(jsonResponse(search({ status: 'dispatched', answer: null, sources: [], search_queries: [], billing: { mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00', usage_amount: 1, unit: 'search_request', }, }), 202, { 'retry-after': '1' })) .mockRejectedValueOnce(new TypeError('connection lost')) .mockResolvedValueOnce(jsonResponse(search())); const client = new WebSearchClient({ fetchImpl, getAccessToken: vi.fn(async () => 'token') as never, now: () => clock, sleep: async (milliseconds) => { clock += milliseconds; }, }); await expect(client.search(input())).resolves.toMatchObject({ status: 'succeeded' }); expect(fetchImpl).toHaveBeenCalledTimes(3); expect(String(fetchImpl.mock.calls[0]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[1]?.[1]?.body)); expect(String(fetchImpl.mock.calls[1]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[2]?.[1]?.body)); }); it('returns receipt_unavailable when the bounded reconciliation window is exhausted', async () => { let clock = 0; const fetchImpl = vi.fn().mockRejectedValue(new TypeError('offline')); const client = new WebSearchClient({ fetchImpl, getAccessToken: vi.fn(async () => 'token') as never, now: () => clock, sleep: async () => { clock = 155_000; }, }); await expect(client.search(input())).rejects.toMatchObject({ code: 'plugin_receipt_unavailable', status: 503, retryable: false, }); expect(fetchImpl).toHaveBeenCalledTimes(2); }); });