/** * Tests for v1 knowledge search API route. * Specifically guards the per-KB embedding model resolution and the * multi-model rejection so the v1 endpoint stays in lockstep with the * internal route. * * @vitest-environment node */ import { createMockRequest, knowledgeApiUtilsMock, knowledgeApiUtilsMockFns } from '@sim/testing' import { getErrorMessage } from '@sim/utils/errors' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockHandleVectorOnlySearch, mockHandleTagOnlySearch, mockHandleTagAndVectorSearch, mockGetQueryStrategy, mockGenerateSearchEmbedding, mockGetDocumentMetadataByIds, mockAuthenticateRequest, mockValidateWorkspaceAccess, } = vi.hoisted(() => ({ mockHandleVectorOnlySearch: vi.fn(), mockHandleTagOnlySearch: vi.fn(), mockHandleTagAndVectorSearch: vi.fn(), mockGetQueryStrategy: vi.fn(), mockGenerateSearchEmbedding: vi.fn(), mockGetDocumentMetadataByIds: vi.fn(), mockAuthenticateRequest: vi.fn(), mockValidateWorkspaceAccess: vi.fn(), })) vi.mock('@/app/api/knowledge/search/utils', () => ({ handleVectorOnlySearch: mockHandleVectorOnlySearch, handleTagOnlySearch: mockHandleTagOnlySearch, handleTagAndVectorSearch: mockHandleTagAndVectorSearch, getQueryStrategy: mockGetQueryStrategy, generateSearchEmbedding: mockGenerateSearchEmbedding, getDocumentMetadataByIds: mockGetDocumentMetadataByIds, })) vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock) vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ checkActorUsageLimits: vi.fn().mockResolvedValue({ isExceeded: false }), })) vi.mock('@/app/api/v1/middleware', () => ({ authenticateRequest: mockAuthenticateRequest, validateWorkspaceAccess: mockValidateWorkspaceAccess, })) vi.mock('@/app/api/v1/knowledge/utils', () => ({ handleError: (e: unknown) => new Response(JSON.stringify({ error: getErrorMessage(e, 'error') }), { status: 500, }), })) vi.mock('@/lib/knowledge/tags/service', () => ({ getDocumentTagDefinitions: vi.fn().mockResolvedValue([]), })) import { POST } from '@/app/api/v1/knowledge/search/route' const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess const baseKb = (id: string, embeddingModel: string) => ({ id, userId: 'user-1', name: `KB ${id}`, workspaceId: 'ws-1', embeddingModel, deletedAt: null, }) describe('v1 knowledge search route — per-KB embedding model', () => { beforeEach(() => { vi.clearAllMocks() mockAuthenticateRequest.mockResolvedValue({ requestId: 'req-1', userId: 'user-1', rateLimit: {}, }) mockValidateWorkspaceAccess.mockResolvedValue(null) mockGetQueryStrategy.mockReturnValue({ distanceThreshold: 0.5 }) mockGenerateSearchEmbedding.mockResolvedValue([0.1, 0.2, 0.3]) mockHandleVectorOnlySearch.mockResolvedValue([]) mockGetDocumentMetadataByIds.mockResolvedValue({}) }) it('passes the KB embedding model into generateSearchEmbedding', async () => { mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'), }) const req = createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-gemini', query: 'hello', }) const res = await POST(req) expect(res.status).toBe(200) expect(mockGenerateSearchEmbedding).toHaveBeenCalledWith( 'hello', 'gemini-embedding-001', 'ws-1' ) }) it('rejects cross-KB queries with mixed embedding models', async () => { mockCheckKnowledgeBaseAccess .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-openai', 'text-embedding-3-small'), }) .mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-gemini', 'gemini-embedding-001'), }) const req = createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: ['kb-openai', 'kb-gemini'], query: 'hello', }) const res = await POST(req) expect(res.status).toBe(400) expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() }) it('surfaces sourceUrl from document metadata in search results', async () => { mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-confluence', 'text-embedding-3-small'), }) mockHandleVectorOnlySearch.mockResolvedValue([ { documentId: 'doc-confluence', knowledgeBaseId: 'kb-confluence', content: 'page content', chunkIndex: 0, distance: 0.1, }, ]) mockGetDocumentMetadataByIds.mockResolvedValue({ 'doc-confluence': { filename: 'Runbook.md', sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345', }, }) const req = createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-confluence', query: 'runbook', }) const res = await POST(req) const body = await res.json() expect(res.status).toBe(200) expect(body.data.results[0].sourceUrl).toBe( 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345' ) expect(body.data.results[0].documentName).toBe('Runbook.md') }) it('allows tag-only search across mixed embedding models', async () => { mockHandleTagOnlySearch.mockResolvedValue([]) mockCheckKnowledgeBaseAccess.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: baseKb('kb-mixed', 'text-embedding-3-small'), }) const req = createMockRequest('POST', { workspaceId: 'ws-1', knowledgeBaseIds: 'kb-mixed', tagFilters: [{ tagName: 'category', operator: 'eq', value: 'docs' }], }) const res = await POST(req) expect(res.status).toBe(400) // tagName "category" is undefined in our empty getDocumentTagDefinitions mock, // so the route returns 400 before reaching the search handlers — but crucially // it never tries to generate an embedding. expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() }) })