mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✅ test: add unit tests for search provider implementations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
235
src/server/services/search/impls/brave/index.test.ts
Normal file
235
src/server/services/search/impls/brave/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
src/server/services/search/impls/exa/index.test.ts
Normal file
245
src/server/services/search/impls/exa/index.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
241
src/server/services/search/impls/google/index.test.ts
Normal file
241
src/server/services/search/impls/google/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
262
src/server/services/search/impls/tavily/index.test.ts
Normal file
262
src/server/services/search/impls/tavily/index.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user