mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✅ test: add unit tests for search impls (brave, exa, tavily) (#12960)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
269
src/server/services/search/impls/brave/index.test.ts
Normal file
269
src/server/services/search/impls/brave/index.test.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BraveImpl } from './index';
|
||||
|
||||
const createMockResponse = (body: object, ok = true, status = 200, statusText = 'OK') =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
||||
}) as unknown as Response;
|
||||
|
||||
const makeBraveResponse = (results: object[]) => ({
|
||||
type: 'search',
|
||||
mixed: {},
|
||||
web: {
|
||||
type: 'web',
|
||||
results,
|
||||
},
|
||||
});
|
||||
|
||||
describe('BraveImpl', () => {
|
||||
let impl: BraveImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
impl = new BraveImpl();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
process.env.BRAVE_API_KEY = 'test-brave-api-key';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('should return mapped results for a successful query', async () => {
|
||||
const braveResults = [
|
||||
{
|
||||
title: 'Example Title',
|
||||
url: 'https://example.com/page',
|
||||
description: 'Example description',
|
||||
type: 'web',
|
||||
},
|
||||
{
|
||||
title: 'Another Result',
|
||||
url: 'https://another.com/page',
|
||||
description: 'Another description',
|
||||
type: 'web',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse(braveResults)));
|
||||
|
||||
const result = await impl.query('test query');
|
||||
|
||||
expect(result.query).toBe('test query');
|
||||
expect(result.resultNumbers).toBe(2);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]).toMatchObject({
|
||||
title: 'Example Title',
|
||||
url: 'https://example.com/page',
|
||||
content: 'Example description',
|
||||
engines: ['brave'],
|
||||
category: 'general',
|
||||
score: 1,
|
||||
parsedUrl: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty results when web.results is empty', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
const result = await impl.query('empty query');
|
||||
|
||||
expect(result.resultNumbers).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include freshness param for day time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'day' });
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const url = fetchCall[0] as string;
|
||||
expect(url).toContain('freshness=pd');
|
||||
});
|
||||
|
||||
it('should include freshness=pw for week time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'week' });
|
||||
|
||||
const url = vi.mocked(fetch).mock.calls[0][0] as string;
|
||||
expect(url).toContain('freshness=pw');
|
||||
});
|
||||
|
||||
it('should include freshness=pm for month time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'month' });
|
||||
|
||||
const url = vi.mocked(fetch).mock.calls[0][0] as string;
|
||||
expect(url).toContain('freshness=pm');
|
||||
});
|
||||
|
||||
it('should include freshness=py for year time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'year' });
|
||||
|
||||
const url = vi.mocked(fetch).mock.calls[0][0] as string;
|
||||
expect(url).toContain('freshness=py');
|
||||
});
|
||||
|
||||
it('should not include a valid freshness value for anytime time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'anytime' });
|
||||
|
||||
const url = vi.mocked(fetch).mock.calls[0][0] as string;
|
||||
// Should not include any of the valid freshness values
|
||||
expect(url).not.toContain('freshness=pd');
|
||||
expect(url).not.toContain('freshness=pw');
|
||||
expect(url).not.toContain('freshness=pm');
|
||||
expect(url).not.toContain('freshness=py');
|
||||
});
|
||||
|
||||
it('should use the API key in request headers', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['X-Subscription-Token']).toBe(
|
||||
'test-brave-api-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use empty string for API key when not set', async () => {
|
||||
delete process.env.BRAVE_API_KEY;
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['X-Subscription-Token']).toBe('');
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when fetch throws a network error', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Failed to connect to Brave.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when response is not ok', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
createMockResponse({ error: 'Unauthorized' }, false, 401, 'Unauthorized'),
|
||||
);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Brave request failed: Unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw INTERNAL_SERVER_ERROR when response JSON parsing fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockRejectedValue(new Error('Invalid JSON')),
|
||||
text: vi.fn().mockResolvedValue('invalid json'),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to parse Brave response.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly parse parsedUrl from result url', async () => {
|
||||
const braveResults = [
|
||||
{
|
||||
title: 'Test',
|
||||
url: 'https://subdomain.example.com/path?query=1',
|
||||
description: 'Test desc',
|
||||
type: 'web',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse(braveResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].parsedUrl).toBe('subdomain.example.com');
|
||||
});
|
||||
|
||||
it('should return empty parsedUrl for invalid url', async () => {
|
||||
const braveResults = [
|
||||
{
|
||||
title: 'Test',
|
||||
url: 'not-a-valid-url',
|
||||
description: 'Test desc',
|
||||
type: 'web',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse(braveResults)));
|
||||
|
||||
// Should handle URL parsing errors gracefully or return empty string
|
||||
// BraveImpl uses new URL(result.url) which throws for invalid URLs
|
||||
await expect(impl.query('test')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should include costTime in the response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(typeof result.costTime).toBe('number');
|
||||
expect(result.costTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should use GET method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect(options.method).toBe('GET');
|
||||
});
|
||||
|
||||
it('should include query string in request URL', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse([])));
|
||||
|
||||
await impl.query('my search query');
|
||||
|
||||
const url = vi.mocked(fetch).mock.calls[0][0] as string;
|
||||
expect(url).toContain('q=my+search+query');
|
||||
});
|
||||
|
||||
it('should use empty string for description when not provided', async () => {
|
||||
const braveResults = [
|
||||
{
|
||||
title: 'No Desc',
|
||||
url: 'https://example.com',
|
||||
description: '',
|
||||
type: 'web',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeBraveResponse(braveResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].content).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
281
src/server/services/search/impls/exa/index.test.ts
Normal file
281
src/server/services/search/impls/exa/index.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ExaImpl } from './index';
|
||||
|
||||
const createMockResponse = (body: object, ok = true, status = 200, statusText = 'OK') =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
||||
}) as unknown as Response;
|
||||
|
||||
const makeExaResponse = (results: object[]) => ({
|
||||
requestId: 'req-123',
|
||||
resolvedSearchType: 'auto',
|
||||
results,
|
||||
});
|
||||
|
||||
describe('ExaImpl', () => {
|
||||
let impl: ExaImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
impl = new ExaImpl();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
process.env.EXA_API_KEY = 'test-exa-api-key';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.EXA_API_KEY;
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('should return mapped results for a successful query', async () => {
|
||||
const exaResults = [
|
||||
{
|
||||
id: 'result-1',
|
||||
url: 'https://example.com/page',
|
||||
title: 'Example Title',
|
||||
text: 'Example content text',
|
||||
score: 0.95,
|
||||
publishedDate: '2024-01-01',
|
||||
},
|
||||
{
|
||||
id: 'result-2',
|
||||
url: 'https://another.com/page',
|
||||
title: 'Another Result',
|
||||
text: 'Another content text',
|
||||
score: 0.85,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse(exaResults)));
|
||||
|
||||
const result = await impl.query('test query');
|
||||
|
||||
expect(result.query).toBe('test query');
|
||||
expect(result.resultNumbers).toBe(2);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]).toMatchObject({
|
||||
title: 'Example Title',
|
||||
url: 'https://example.com/page',
|
||||
content: 'Example content text',
|
||||
engines: ['exa'],
|
||||
category: 'general',
|
||||
score: 0.95,
|
||||
parsedUrl: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty results when results array is empty', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
const result = await impl.query('empty query');
|
||||
|
||||
expect(result.resultNumbers).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle missing results field gracefully', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse({ requestId: 'req-123' }));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.resultNumbers).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should set date range for day time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'day' });
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
const body = JSON.parse(options.body as string);
|
||||
|
||||
expect(body.startPublishedDate).toBeDefined();
|
||||
expect(body.endPublishedDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should set date range for week time range', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'week' });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.startPublishedDate).toBeDefined();
|
||||
expect(body.endPublishedDate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not set date range for anytime', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'anytime' });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.startPublishedDate).toBeUndefined();
|
||||
expect(body.endPublishedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should filter category to news only', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test', { searchCategories: ['news', 'general'] });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.category).toBe('news');
|
||||
});
|
||||
|
||||
it('should not include category for non-news categories', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test', { searchCategories: ['general'] });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.category).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include API key in request headers', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['x-api-key']).toBe('test-exa-api-key');
|
||||
});
|
||||
|
||||
it('should use empty string for API key when not set', async () => {
|
||||
delete process.env.EXA_API_KEY;
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['x-api-key']).toBe('');
|
||||
});
|
||||
|
||||
it('should use POST method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should include query in request body', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
await impl.query('my search query');
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.query).toBe('my search query');
|
||||
expect(body.numResults).toBe(15);
|
||||
expect(body.type).toBe('auto');
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when fetch throws a network error', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Failed to connect to Exa.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when response is not ok', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
createMockResponse({ error: 'Forbidden' }, false, 403, 'Forbidden'),
|
||||
);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Exa request failed: Forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw INTERNAL_SERVER_ERROR when response JSON parsing fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockRejectedValue(new Error('JSON parse error')),
|
||||
text: vi.fn().mockResolvedValue('not json'),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to parse Exa response.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should use score 0 when result score is undefined', async () => {
|
||||
const exaResults = [
|
||||
{
|
||||
id: 'result-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Test',
|
||||
text: 'Content',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse(exaResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].score).toBe(0);
|
||||
});
|
||||
|
||||
it('should use category from body for result category when news', async () => {
|
||||
const exaResults = [
|
||||
{
|
||||
id: 'result-1',
|
||||
url: 'https://example.com',
|
||||
title: 'News Article',
|
||||
text: 'News content',
|
||||
score: 0.9,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse(exaResults)));
|
||||
|
||||
const result = await impl.query('test', { searchCategories: ['news'] });
|
||||
|
||||
expect(result.results[0].category).toBe('news');
|
||||
});
|
||||
|
||||
it('should include costTime in the response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse([])));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(typeof result.costTime).toBe('number');
|
||||
expect(result.costTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should use empty content when text is missing', async () => {
|
||||
const exaResults = [
|
||||
{
|
||||
id: 'result-1',
|
||||
url: 'https://example.com',
|
||||
title: 'Test',
|
||||
text: '',
|
||||
score: 0.9,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeExaResponse(exaResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].content).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
311
src/server/services/search/impls/tavily/index.test.ts
Normal file
311
src/server/services/search/impls/tavily/index.test.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// @vitest-environment node
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TavilyImpl } from './index';
|
||||
|
||||
const createMockResponse = (body: object, ok = true, status = 200, statusText = 'OK') =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
statusText,
|
||||
json: vi.fn().mockResolvedValue(body),
|
||||
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
|
||||
}) as unknown as Response;
|
||||
|
||||
const makeTavilyResponse = (results: object[], query = 'test') => ({
|
||||
query,
|
||||
response_time: 0.5,
|
||||
results,
|
||||
});
|
||||
|
||||
describe('TavilyImpl', () => {
|
||||
let impl: TavilyImpl;
|
||||
|
||||
beforeEach(() => {
|
||||
impl = new TavilyImpl();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
process.env.TAVILY_API_KEY = 'test-tavily-api-key';
|
||||
delete process.env.TAVILY_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
delete process.env.TAVILY_SEARCH_DEPTH;
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('should return mapped results for a successful query', async () => {
|
||||
const tavilyResults = [
|
||||
{
|
||||
title: 'Example Title',
|
||||
url: 'https://example.com/page',
|
||||
content: 'Example content text',
|
||||
score: 0.9,
|
||||
},
|
||||
{
|
||||
title: 'Another Result',
|
||||
url: 'https://another.com/article',
|
||||
content: 'Another content text',
|
||||
score: 0.7,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(
|
||||
createMockResponse(makeTavilyResponse(tavilyResults, 'test query')),
|
||||
);
|
||||
|
||||
const result = await impl.query('test query');
|
||||
|
||||
expect(result.query).toBe('test query');
|
||||
expect(result.resultNumbers).toBe(2);
|
||||
expect(result.results).toHaveLength(2);
|
||||
expect(result.results[0]).toMatchObject({
|
||||
title: 'Example Title',
|
||||
url: 'https://example.com/page',
|
||||
content: 'Example content text',
|
||||
engines: ['tavily'],
|
||||
category: 'general',
|
||||
score: 0.9,
|
||||
parsedUrl: 'example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty results when results array is empty', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
const result = await impl.query('empty query');
|
||||
|
||||
expect(result.resultNumbers).toBe(0);
|
||||
expect(result.results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should set time_range for day', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'day' });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.time_range).toBe('day');
|
||||
});
|
||||
|
||||
it('should set time_range for week', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'week' });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.time_range).toBe('week');
|
||||
});
|
||||
|
||||
it('should not set time_range for anytime', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchTimeRange: 'anytime' });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.time_range).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set topic to news when news category included', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchCategories: ['news', 'general'] });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.topic).toBe('news');
|
||||
});
|
||||
|
||||
it('should set topic to general when general category included', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchCategories: ['general'] });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.topic).toBe('general');
|
||||
});
|
||||
|
||||
it('should not include topic for unsupported categories', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test', { searchCategories: ['images', 'videos'] });
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.topic).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use default search_depth basic when env not set', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.search_depth).toBe('basic');
|
||||
});
|
||||
|
||||
it('should use TAVILY_SEARCH_DEPTH from env when set', async () => {
|
||||
process.env.TAVILY_SEARCH_DEPTH = 'advanced';
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.search_depth).toBe('advanced');
|
||||
});
|
||||
|
||||
it('should include Bearer token in authorization header', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['Authorization']).toBe(
|
||||
'Bearer test-tavily-api-key',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use empty string in authorization header when API key not set', async () => {
|
||||
delete process.env.TAVILY_API_KEY;
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect((options.headers as Record<string, string>)['Authorization']).toBe('');
|
||||
});
|
||||
|
||||
it('should use POST method', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('test');
|
||||
|
||||
const fetchCall = vi.mocked(fetch).mock.calls[0];
|
||||
const options = fetchCall[1] as RequestInit;
|
||||
expect(options.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('should include query in request body', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
await impl.query('my search query');
|
||||
|
||||
const body = JSON.parse((vi.mocked(fetch).mock.calls[0][1] as RequestInit).body as string);
|
||||
expect(body.query).toBe('my search query');
|
||||
expect(body.max_results).toBe(15);
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when fetch throws a network error', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Failed to connect to Tavily.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw SERVICE_UNAVAILABLE when response is not ok', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
createMockResponse({ error: 'Too Many Requests' }, false, 429, 'Too Many Requests'),
|
||||
);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: 'Tavily request failed: Too Many Requests',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw INTERNAL_SERVER_ERROR when response JSON parsing fails', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: vi.fn().mockRejectedValue(new Error('JSON error')),
|
||||
text: vi.fn().mockResolvedValue('bad json'),
|
||||
} as unknown as Response);
|
||||
|
||||
await expect(impl.query('test')).rejects.toMatchObject({
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to parse Tavily response.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should default score to 0 when not provided', async () => {
|
||||
const tavilyResults = [
|
||||
{
|
||||
title: 'No Score',
|
||||
url: 'https://example.com',
|
||||
content: 'Content',
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse(tavilyResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].score).toBe(0);
|
||||
});
|
||||
|
||||
it('should use topic as category in mapped results', async () => {
|
||||
const tavilyResults = [
|
||||
{
|
||||
title: 'News Article',
|
||||
url: 'https://example.com',
|
||||
content: 'News content',
|
||||
score: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse(tavilyResults)));
|
||||
|
||||
const result = await impl.query('test', { searchCategories: ['news'] });
|
||||
|
||||
expect(result.results[0].category).toBe('news');
|
||||
});
|
||||
|
||||
it('should default category to general when topic not set', async () => {
|
||||
const tavilyResults = [
|
||||
{
|
||||
title: 'Article',
|
||||
url: 'https://example.com',
|
||||
content: 'Content',
|
||||
score: 0.8,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse(tavilyResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].category).toBe('general');
|
||||
});
|
||||
|
||||
it('should correctly parse parsedUrl from result url', async () => {
|
||||
const tavilyResults = [
|
||||
{
|
||||
title: 'Test',
|
||||
url: 'https://www.example.co.uk/path',
|
||||
content: 'Content',
|
||||
score: 0.5,
|
||||
},
|
||||
];
|
||||
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse(tavilyResults)));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(result.results[0].parsedUrl).toBe('www.example.co.uk');
|
||||
});
|
||||
|
||||
it('should include costTime in the response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(createMockResponse(makeTavilyResponse([])));
|
||||
|
||||
const result = await impl.query('test');
|
||||
|
||||
expect(typeof result.costTime).toBe('number');
|
||||
expect(result.costTime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user