From 54964c3fb766c0340b238f91c02aec3fbcd75c15 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 05:53:56 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20add=20unit=20tests=20for=20?= =?UTF-8?q?search=20provider=20implementations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../services/search/impls/brave/index.test.ts | 235 ++++++++++++++++ .../services/search/impls/exa/index.test.ts | 245 ++++++++++++++++ .../search/impls/google/index.test.ts | 241 ++++++++++++++++ .../search/impls/tavily/index.test.ts | 262 ++++++++++++++++++ 4 files changed, 983 insertions(+) create mode 100644 src/server/services/search/impls/brave/index.test.ts create mode 100644 src/server/services/search/impls/exa/index.test.ts create mode 100644 src/server/services/search/impls/google/index.test.ts create mode 100644 src/server/services/search/impls/tavily/index.test.ts diff --git a/src/server/services/search/impls/brave/index.test.ts b/src/server/services/search/impls/brave/index.test.ts new file mode 100644 index 0000000000..731e89f234 --- /dev/null +++ b/src/server/services/search/impls/brave/index.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/services/search/impls/exa/index.test.ts b/src/server/services/search/impls/exa/index.test.ts new file mode 100644 index 0000000000..94479a0e5d --- /dev/null +++ b/src/server/services/search/impls/exa/index.test.ts @@ -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'); + }); + }); +}); diff --git a/src/server/services/search/impls/google/index.test.ts b/src/server/services/search/impls/google/index.test.ts new file mode 100644 index 0000000000..401ca18a81 --- /dev/null +++ b/src/server/services/search/impls/google/index.test.ts @@ -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); + }); + }); +}); diff --git a/src/server/services/search/impls/tavily/index.test.ts b/src/server/services/search/impls/tavily/index.test.ts new file mode 100644 index 0000000000..fcdcfe2db1 --- /dev/null +++ b/src/server/services/search/impls/tavily/index.test.ts @@ -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); + }); + }); +});