test: add unit tests for search provider implementations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude[bot]
2026-03-09 05:53:56 +00:00
parent c6de80931e
commit 54964c3fb7
4 changed files with 983 additions and 0 deletions

View File

@@ -0,0 +1,235 @@
// @vitest-environment node
import { TRPCError } from '@trpc/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { BraveImpl } from './index';
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockBraveResponse = {
mixed: {},
type: 'search',
web: {
results: [
{
description: 'This is test content',
title: 'Test Title',
type: 'SearchResult',
url: 'https://example.com/page',
},
{
description: 'Another result',
title: 'Another Title',
type: 'SearchResult',
url: 'https://another.com/page',
},
],
type: 'search',
},
};
describe('BraveImpl', () => {
let impl: BraveImpl;
beforeEach(() => {
impl = new BraveImpl();
vi.clearAllMocks();
process.env.BRAVE_API_KEY = 'test-api-key';
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.BRAVE_API_KEY;
});
describe('query', () => {
it('should return mapped results on successful response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve(mockBraveResponse),
ok: true,
});
const result = await impl.query('test');
expect(result.query).toBe('test');
expect(result.resultNumbers).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0]).toMatchObject({
category: 'general',
content: 'This is test content',
engines: ['brave'],
parsedUrl: 'example.com',
score: 1,
title: 'Test Title',
url: 'https://example.com/page',
});
});
it('should use GET method with query parameters', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test query');
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(url).toContain('q=test+query');
});
it('should use X-Subscription-Token header with API key', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['X-Subscription-Token']).toBe('test-api-key');
});
it('should use empty token when no API key', async () => {
delete process.env.BRAVE_API_KEY;
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['X-Subscription-Token']).toBe('');
});
it('should map day searchTimeRange to "pd" freshness', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'day' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('freshness=pd');
});
it('should map week searchTimeRange to "pw" freshness', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'week' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('freshness=pw');
});
it('should map month searchTimeRange to "pm" freshness', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'month' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('freshness=pm');
});
it('should map year searchTimeRange to "py" freshness', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'year' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('freshness=py');
});
it('should not include freshness when searchTimeRange is "anytime"', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'anytime' });
const [url] = mockFetch.mock.calls[0];
// When anytime, freshness should not be set to one of the valid time range 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 throw TRPCError SERVICE_UNAVAILABLE on network error', async () => {
mockFetch.mockRejectedValue(new Error('Connection refused'));
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Failed to connect to Brave.',
});
});
it('should throw TRPCError SERVICE_UNAVAILABLE on non-OK response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 429,
statusText: 'Too Many Requests',
text: () => Promise.resolve('Rate limit exceeded'),
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Brave request failed: Too Many Requests',
});
});
it('should throw TRPCError INTERNAL_SERVER_ERROR on parse error', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.reject(new Error('Bad JSON')),
ok: true,
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse Brave response.',
});
});
it('should handle empty web results', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
...mockBraveResponse,
web: { results: [], type: 'search' },
}),
ok: true,
});
const result = await impl.query('test');
expect(result.results).toHaveLength(0);
expect(result.resultNumbers).toBe(0);
});
it('should include costTime in response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockBraveResponse, web: { results: [], type: 'search' } }),
ok: true,
});
const result = await impl.query('test');
expect(typeof result.costTime).toBe('number');
expect(result.costTime).toBeGreaterThanOrEqual(0);
});
});
});

View File

@@ -0,0 +1,245 @@
// @vitest-environment node
import { TRPCError } from '@trpc/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ExaImpl } from './index';
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockExaResponse = {
results: [
{
score: 0.85,
text: 'This is test content',
title: 'Test Title',
url: 'https://example.com/page',
},
{
score: 0.7,
text: 'Another result',
title: 'Another Title',
url: 'https://another.com/page',
},
],
};
describe('ExaImpl', () => {
let impl: ExaImpl;
beforeEach(() => {
impl = new ExaImpl();
vi.clearAllMocks();
process.env.EXA_API_KEY = 'test-exa-key';
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.EXA_API_KEY;
});
describe('query', () => {
it('should return mapped results on successful response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve(mockExaResponse),
ok: true,
});
const result = await impl.query('test');
expect(result.query).toBe('test');
expect(result.resultNumbers).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0]).toMatchObject({
category: 'general',
content: 'This is test content',
engines: ['exa'],
parsedUrl: 'example.com',
score: 0.85,
title: 'Test Title',
url: 'https://example.com/page',
});
});
it('should use x-api-key header for authorization', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['x-api-key']).toBe('test-exa-key');
});
it('should use empty api key when EXA_API_KEY not set', async () => {
delete process.env.EXA_API_KEY;
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['x-api-key']).toBe('');
});
it('should include date range for "day" searchTimeRange', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
const before = Date.now();
await impl.query('test', { searchTimeRange: 'day' });
const after = Date.now();
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.startPublishedDate).toBeDefined();
expect(body.endPublishedDate).toBeDefined();
const start = new Date(body.startPublishedDate).getTime();
const end = new Date(body.endPublishedDate).getTime();
expect(end - start).toBeCloseTo(1 * 86_400 * 1000, -3); // ~1 day
});
it('should include date range for "week" searchTimeRange', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'week' });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.startPublishedDate).toBeDefined();
expect(body.endPublishedDate).toBeDefined();
const diff =
new Date(body.endPublishedDate).getTime() - new Date(body.startPublishedDate).getTime();
expect(diff).toBeCloseTo(7 * 86_400 * 1000, -3);
});
it('should not include date range for "anytime" searchTimeRange', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'anytime' });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.startPublishedDate).toBeUndefined();
expect(body.endPublishedDate).toBeUndefined();
});
it('should set category to news for news searchCategory', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
results: [{ text: 'c', title: 't', url: 'https://x.com' }],
}),
ok: true,
});
const result = await impl.query('test', { searchCategories: ['news'] });
expect(result.results[0].category).toBe('news');
});
it('should default category to "general" when not news', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
results: [{ text: 'c', title: 't', url: 'https://x.com' }],
}),
ok: true,
});
const result = await impl.query('test', { searchCategories: ['general'] });
expect(result.results[0].category).toBe('general');
});
it('should throw TRPCError SERVICE_UNAVAILABLE on network error', async () => {
mockFetch.mockRejectedValue(new Error('Connection refused'));
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Failed to connect to Exa.',
});
});
it('should throw TRPCError SERVICE_UNAVAILABLE on non-OK response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
text: () => Promise.resolve('Access denied'),
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Exa request failed: Forbidden',
});
});
it('should throw TRPCError INTERNAL_SERVER_ERROR on parse error', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.reject(new Error('Malformed JSON')),
ok: true,
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse Exa response.',
});
});
it('should handle missing score with default 0', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
results: [{ text: 'content', title: 'Title', url: 'https://x.com' }],
}),
ok: true,
});
const result = await impl.query('test');
expect(result.results[0].score).toBe(0);
});
it('should handle empty results array', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
const result = await impl.query('test');
expect(result.results).toHaveLength(0);
expect(result.resultNumbers).toBe(0);
});
it('should use POST method with JSON body', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ results: [] }),
ok: true,
});
await impl.query('hello world');
const [, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('POST');
const body = JSON.parse(options.body);
expect(body.query).toBe('hello world');
});
});
});

View File

@@ -0,0 +1,241 @@
// @vitest-environment node
import { TRPCError } from '@trpc/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GoogleImpl } from './index';
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockGoogleResponse = {
items: [
{
link: 'https://example.com/page',
snippet: 'This is test content',
title: 'Test Title',
},
{
link: 'https://another.com/page',
snippet: 'Another result',
title: 'Another Title',
},
],
kind: 'customsearch#search',
};
describe('GoogleImpl', () => {
let impl: GoogleImpl;
beforeEach(() => {
impl = new GoogleImpl();
vi.clearAllMocks();
process.env.GOOGLE_PSE_API_KEY = 'test-google-key';
process.env.GOOGLE_PSE_ENGINE_ID = 'test-engine-id';
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.GOOGLE_PSE_API_KEY;
delete process.env.GOOGLE_PSE_ENGINE_ID;
});
describe('query', () => {
it('should return mapped results on successful response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve(mockGoogleResponse),
ok: true,
});
const result = await impl.query('test');
expect(result.query).toBe('test');
expect(result.resultNumbers).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0]).toMatchObject({
category: 'general',
content: 'This is test content',
engines: ['google'],
parsedUrl: 'example.com',
score: 1,
title: 'Test Title',
url: 'https://example.com/page',
});
});
it('should use GET method with query parameters', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('hello world');
const [url, options] = mockFetch.mock.calls[0];
expect(options.method).toBe('GET');
expect(url).toContain('q=hello+world');
});
it('should include API key and engine ID in request', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test');
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('key=test-google-key');
expect(url).toContain('cx=test-engine-id');
});
it('should use empty strings when env vars not set', async () => {
delete process.env.GOOGLE_PSE_API_KEY;
delete process.env.GOOGLE_PSE_ENGINE_ID;
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test');
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('key=');
expect(url).toContain('cx=');
});
it('should map day searchTimeRange to "d1" dateRestrict', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'day' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('dateRestrict=d1');
});
it('should map week searchTimeRange to "w1" dateRestrict', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'week' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('dateRestrict=w1');
});
it('should map month searchTimeRange to "m1" dateRestrict', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'month' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('dateRestrict=m1');
});
it('should map year searchTimeRange to "y1" dateRestrict', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'year' });
const [url] = mockFetch.mock.calls[0];
expect(url).toContain('dateRestrict=y1');
});
it('should not include dateRestrict when searchTimeRange is "anytime"', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'anytime' });
const [url] = mockFetch.mock.calls[0];
// When anytime, dateRestrict should not be set to a valid time range value
expect(url).not.toContain('dateRestrict=d1');
expect(url).not.toContain('dateRestrict=w1');
expect(url).not.toContain('dateRestrict=m1');
expect(url).not.toContain('dateRestrict=y1');
});
it('should throw TRPCError SERVICE_UNAVAILABLE on network error', async () => {
mockFetch.mockRejectedValue(new Error('DNS lookup failed'));
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Failed to connect to Google.',
});
});
it('should throw TRPCError SERVICE_UNAVAILABLE on non-OK response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 403,
statusText: 'Forbidden',
text: () => Promise.resolve('Daily limit exceeded'),
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Google request failed: Forbidden',
});
});
it('should throw TRPCError INTERNAL_SERVER_ERROR on parse error', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.reject(new Error('Invalid JSON')),
ok: true,
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse Google response.',
});
});
it('should handle empty items array', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
const result = await impl.query('test');
expect(result.results).toHaveLength(0);
expect(result.resultNumbers).toBe(0);
});
it('should handle missing items with empty results', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({}),
ok: true,
});
const result = await impl.query('test');
expect(result.results).toHaveLength(0);
});
it('should include costTime in response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ items: [] }),
ok: true,
});
const result = await impl.query('test');
expect(typeof result.costTime).toBe('number');
expect(result.costTime).toBeGreaterThanOrEqual(0);
});
});
});

View File

@@ -0,0 +1,262 @@
// @vitest-environment node
import { TRPCError } from '@trpc/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TavilyImpl } from './index';
const mockFetch = vi.fn();
global.fetch = mockFetch;
const mockTavilyResponse = {
query: 'test',
response_time: 0.5,
results: [
{
content: 'This is test content',
score: 0.9,
title: 'Test Title',
url: 'https://example.com/page',
},
{
content: 'Another result',
score: 0.7,
title: 'Another Title',
url: 'https://another.com/page',
},
],
};
describe('TavilyImpl', () => {
let impl: TavilyImpl;
beforeEach(() => {
impl = new TavilyImpl();
vi.clearAllMocks();
process.env.TAVILY_API_KEY = 'test-api-key';
delete process.env.TAVILY_SEARCH_DEPTH;
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env.TAVILY_API_KEY;
delete process.env.TAVILY_SEARCH_DEPTH;
});
describe('query', () => {
it('should return mapped results on successful response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve(mockTavilyResponse),
ok: true,
});
const result = await impl.query('test');
expect(result.query).toBe('test');
expect(result.resultNumbers).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0]).toMatchObject({
category: 'general',
content: 'This is test content',
engines: ['tavily'],
parsedUrl: 'example.com',
score: 0.9,
title: 'Test Title',
url: 'https://example.com/page',
});
});
it('should use Bearer token authorization header', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['Authorization']).toBe('Bearer test-api-key');
});
it('should use empty authorization when no API key', async () => {
delete process.env.TAVILY_API_KEY;
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
expect(options.headers['Authorization']).toBe('');
});
it('should use TAVILY_SEARCH_DEPTH env var when set', async () => {
process.env.TAVILY_SEARCH_DEPTH = 'advanced';
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.search_depth).toBe('advanced');
});
it('should default to basic search_depth', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test');
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.search_depth).toBe('basic');
});
it('should pass time_range when searchTimeRange is not "anytime"', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'day' });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.time_range).toBe('day');
});
it('should not pass time_range when searchTimeRange is "anytime"', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test', { searchTimeRange: 'anytime' });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.time_range).toBeUndefined();
});
it('should set topic from searchCategories for news', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test', { searchCategories: ['news'] });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.topic).toBe('news');
});
it('should set topic from searchCategories for general', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
await impl.query('test', { searchCategories: ['general'] });
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body);
expect(body.topic).toBe('general');
});
it('should return category from body.topic or default to "general"', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
...mockTavilyResponse,
results: [{ content: 'c', score: 1, title: 't', url: 'https://a.com' }],
}),
ok: true,
});
const result = await impl.query('test', { searchCategories: ['news'] });
expect(result.results[0].category).toBe('news');
});
it('should throw TRPCError SERVICE_UNAVAILABLE on network error', async () => {
mockFetch.mockRejectedValue(new Error('Network failure'));
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Failed to connect to Tavily.',
});
});
it('should throw TRPCError SERVICE_UNAVAILABLE on non-OK response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
text: () => Promise.resolve('Invalid API key'),
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'SERVICE_UNAVAILABLE',
message: 'Tavily request failed: Unauthorized',
});
});
it('should throw TRPCError INTERNAL_SERVER_ERROR on parse error', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.reject(new Error('Invalid JSON')),
ok: true,
});
await expect(impl.query('test')).rejects.toThrow(TRPCError);
await expect(impl.query('test')).rejects.toMatchObject({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to parse Tavily response.',
});
});
it('should handle empty results array', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
const result = await impl.query('test');
expect(result.results).toHaveLength(0);
expect(result.resultNumbers).toBe(0);
});
it('should handle missing score with default 0', async () => {
mockFetch.mockResolvedValue({
json: () =>
Promise.resolve({
...mockTavilyResponse,
results: [{ title: 'T', url: 'https://x.com' }],
}),
ok: true,
});
const result = await impl.query('test');
expect(result.results[0].score).toBe(0);
});
it('should include costTime in response', async () => {
mockFetch.mockResolvedValue({
json: () => Promise.resolve({ ...mockTavilyResponse, results: [] }),
ok: true,
});
const result = await impl.query('test');
expect(typeof result.costTime).toBe('number');
expect(result.costTime).toBeGreaterThanOrEqual(0);
});
});
});