421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
// @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<string, unknown> = {}) {
|
|
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<string, string> = {}): 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<typeof fetch>()
|
|
.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<typeof fetch>().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<WebSearchClientError>({
|
|
code: 'plugin_input_invalid', status: 422, retryable: false,
|
|
});
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().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<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
|
sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }],
|
|
}))),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
}).search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().mockResolvedValue(jsonResponse(search({
|
|
sources: [{ title: 'Source', url }],
|
|
}))),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
});
|
|
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().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<WebSearchClientError>({
|
|
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<typeof fetch>().mockResolvedValue(response),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
});
|
|
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().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<typeof fetch>().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<WebSearchClientError>({
|
|
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<typeof fetch>().mockResolvedValue(jsonResponse(search(), 202)),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
});
|
|
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<Uint8Array>({
|
|
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<typeof fetch>().mockResolvedValue(response),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
});
|
|
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().mockResolvedValue(jsonResponse(payload)),
|
|
getAccessToken: vi.fn(async () => 'token') as never,
|
|
});
|
|
|
|
await expect(client.search(input())).rejects.toMatchObject<WebSearchClientError>({
|
|
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<typeof fetch>().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<typeof fetch>()
|
|
.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<typeof fetch>().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<WebSearchClientError>({
|
|
code: 'plugin_receipt_unavailable', status: 503, retryable: false,
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|