d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
1184 lines
36 KiB
TypeScript
1184 lines
36 KiB
TypeScript
/**
|
|
* Tests for knowledge search API route
|
|
* Focuses on route-specific functionality: authentication, validation, API contract, error handling
|
|
* Search logic is tested in utils.test.ts
|
|
*
|
|
* @vitest-environment node
|
|
*/
|
|
import {
|
|
createEnvMock,
|
|
createMockRequest,
|
|
hybridAuthMockFns,
|
|
knowledgeApiUtilsMock,
|
|
knowledgeApiUtilsMockFns,
|
|
workflowAuthzMockFns,
|
|
workflowsUtilsMock,
|
|
} from '@sim/testing'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
mockDbChain,
|
|
mockGetDocumentTagDefinitions,
|
|
mockHandleTagOnlySearch,
|
|
mockHandleVectorOnlySearch,
|
|
mockHandleTagAndVectorSearch,
|
|
mockGetQueryStrategy,
|
|
mockGenerateSearchEmbedding,
|
|
mockGetDocumentMetadataByIds,
|
|
} = vi.hoisted(() => ({
|
|
mockDbChain: {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
orderBy: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockReturnThis(),
|
|
innerJoin: vi.fn().mockReturnThis(),
|
|
leftJoin: vi.fn().mockReturnThis(),
|
|
groupBy: vi.fn().mockReturnThis(),
|
|
having: vi.fn().mockReturnThis(),
|
|
},
|
|
mockGetDocumentTagDefinitions: vi.fn(),
|
|
mockHandleTagOnlySearch: vi.fn(),
|
|
mockHandleVectorOnlySearch: vi.fn(),
|
|
mockHandleTagAndVectorSearch: vi.fn(),
|
|
mockGetQueryStrategy: vi.fn(),
|
|
mockGenerateSearchEmbedding: vi.fn(),
|
|
mockGetDocumentMetadataByIds: vi.fn(),
|
|
}))
|
|
|
|
const mockCheckKnowledgeBaseAccess = knowledgeApiUtilsMockFns.mockCheckKnowledgeBaseAccess
|
|
|
|
vi.mock('drizzle-orm', () => ({
|
|
and: vi.fn().mockImplementation((...args) => ({ and: args })),
|
|
eq: vi.fn().mockImplementation((a, b) => ({ eq: [a, b] })),
|
|
inArray: vi.fn().mockImplementation((field, values) => ({ inArray: [field, values] })),
|
|
isNull: vi.fn().mockImplementation((arg) => ({ isNull: arg })),
|
|
sql: vi.fn().mockImplementation((strings, ...values) => ({
|
|
sql: strings,
|
|
values,
|
|
as: vi.fn().mockReturnValue({ sql: strings, values, alias: 'mocked_alias' }),
|
|
})),
|
|
}))
|
|
|
|
vi.mock('@sim/db', () => ({
|
|
db: mockDbChain,
|
|
}))
|
|
|
|
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
|
|
|
|
vi.mock('@/lib/core/config/env', () => createEnvMock({ OPENAI_API_KEY: 'test-api-key' }))
|
|
|
|
vi.mock('@/lib/documents/utils', () => ({
|
|
retryWithExponentialBackoff: vi.fn().mockImplementation((fn) => fn()),
|
|
}))
|
|
|
|
vi.mock('@/lib/tokenization/estimators', () => ({
|
|
estimateTokenCount: vi.fn().mockReturnValue({ count: 521 }),
|
|
}))
|
|
|
|
vi.mock('@/providers/utils', () => ({
|
|
calculateCost: vi.fn().mockReturnValue({
|
|
input: 0.00001042,
|
|
output: 0,
|
|
total: 0.00001042,
|
|
pricing: {
|
|
input: 0.02,
|
|
output: 0,
|
|
updatedAt: '2025-07-10',
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/app/api/knowledge/utils', () => knowledgeApiUtilsMock)
|
|
|
|
vi.mock('@/lib/knowledge/tags/service', () => ({
|
|
getDocumentTagDefinitions: mockGetDocumentTagDefinitions,
|
|
}))
|
|
|
|
vi.mock('./utils', () => ({
|
|
handleTagOnlySearch: mockHandleTagOnlySearch,
|
|
handleVectorOnlySearch: mockHandleVectorOnlySearch,
|
|
handleTagAndVectorSearch: mockHandleTagAndVectorSearch,
|
|
getQueryStrategy: mockGetQueryStrategy,
|
|
generateSearchEmbedding: mockGenerateSearchEmbedding,
|
|
getDocumentMetadataByIds: mockGetDocumentMetadataByIds,
|
|
APIError: class APIError extends Error {
|
|
public status: number
|
|
constructor(message: string, status: number) {
|
|
super(message)
|
|
this.name = 'APIError'
|
|
this.status = status
|
|
}
|
|
},
|
|
}))
|
|
|
|
import { estimateTokenCount } from '@/lib/tokenization/estimators'
|
|
import { POST } from '@/app/api/knowledge/search/route'
|
|
import { calculateCost } from '@/providers/utils'
|
|
|
|
describe('Knowledge Search API Route', () => {
|
|
const mockGetUserId = vi.fn()
|
|
const mockFetch = vi.fn()
|
|
|
|
const mockEmbedding = [0.1, 0.2, 0.3, 0.4, 0.5]
|
|
const mockSearchResults = [
|
|
{
|
|
id: 'chunk-1',
|
|
content: 'This is a test chunk',
|
|
documentId: 'doc-1',
|
|
chunkIndex: 0,
|
|
metadata: { title: 'Test Document' },
|
|
distance: 0.2,
|
|
},
|
|
{
|
|
id: 'chunk-2',
|
|
content: 'Another test chunk',
|
|
documentId: 'doc-2',
|
|
chunkIndex: 1,
|
|
metadata: { title: 'Another Document' },
|
|
distance: 0.3,
|
|
},
|
|
]
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
|
|
Object.values(mockDbChain).forEach((fn) => {
|
|
if (typeof fn === 'function') {
|
|
fn.mockClear().mockReturnThis()
|
|
}
|
|
})
|
|
|
|
mockHandleTagOnlySearch.mockClear()
|
|
mockHandleVectorOnlySearch.mockClear()
|
|
mockHandleTagAndVectorSearch.mockClear()
|
|
mockGetQueryStrategy.mockClear().mockReturnValue({
|
|
useParallel: false,
|
|
distanceThreshold: 1.0,
|
|
parallelLimit: 15,
|
|
singleQueryOptimized: true,
|
|
})
|
|
mockGenerateSearchEmbedding
|
|
.mockClear()
|
|
.mockResolvedValue({ embedding: [0.1, 0.2, 0.3, 0.4, 0.5], isBYOK: false })
|
|
mockGetDocumentMetadataByIds.mockClear().mockResolvedValue({
|
|
doc1: { filename: 'Document 1', sourceUrl: null },
|
|
doc2: { filename: 'Document 2', sourceUrl: null },
|
|
})
|
|
mockGetDocumentTagDefinitions.mockClear()
|
|
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockClear().mockResolvedValue({
|
|
success: true,
|
|
userId: 'user-123',
|
|
authType: 'session',
|
|
})
|
|
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockClear().mockResolvedValue({
|
|
allowed: true,
|
|
status: 200,
|
|
})
|
|
|
|
vi.stubGlobal('crypto', {
|
|
randomUUID: vi.fn().mockReturnValue('mock-uuid-1234-5678'),
|
|
})
|
|
|
|
vi.stubGlobal('fetch', mockFetch)
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
describe('POST /api/knowledge/search', () => {
|
|
const validSearchData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: 'test search query',
|
|
topK: 10,
|
|
}
|
|
|
|
const mockKnowledgeBases = [
|
|
{
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
]
|
|
|
|
it('should perform search successfully with single knowledge base', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValue([])
|
|
|
|
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(2)
|
|
expect(data.data.results[0].similarity).toBe(0.8) // 1 - 0.2
|
|
expect(data.data.query).toBe(validSearchData.query)
|
|
expect(data.data.knowledgeBaseIds).toEqual(['kb-123'])
|
|
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
|
|
knowledgeBaseIds: ['kb-123'],
|
|
topK: 10,
|
|
queryVector: JSON.stringify(mockEmbedding),
|
|
distanceThreshold: expect.any(Number),
|
|
})
|
|
})
|
|
|
|
it('should perform search successfully with multiple knowledge bases', async () => {
|
|
const multiKbData = {
|
|
...validSearchData,
|
|
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
|
}
|
|
|
|
const multiKbs = [
|
|
...mockKnowledgeBases,
|
|
{ id: 'kb-456', userId: 'user-123', name: 'Test KB 2', deletedAt: null },
|
|
]
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess
|
|
.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[0] })
|
|
.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: multiKbs[1] })
|
|
|
|
mockDbChain.limit.mockResolvedValue([])
|
|
|
|
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', multiKbData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456'])
|
|
expect(mockHandleVectorOnlySearch).toHaveBeenCalledWith({
|
|
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
|
topK: 10,
|
|
queryVector: JSON.stringify(mockEmbedding),
|
|
distanceThreshold: expect.any(Number),
|
|
})
|
|
})
|
|
|
|
it('should handle workflow-based authentication', async () => {
|
|
const workflowData = {
|
|
...validSearchData,
|
|
workflowId: 'workflow-123',
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValue([])
|
|
|
|
mockHandleVectorOnlySearch.mockResolvedValue(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', workflowData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
|
|
workflowId: 'workflow-123',
|
|
userId: 'user-123',
|
|
action: 'read',
|
|
})
|
|
})
|
|
|
|
it.concurrent('should return unauthorized for unauthenticated request', async () => {
|
|
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
|
|
success: false,
|
|
error: 'Unauthorized',
|
|
})
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(401)
|
|
expect(data.error).toBe('Unauthorized')
|
|
})
|
|
|
|
it.concurrent('should return not found for workflow that does not exist', async () => {
|
|
const workflowData = {
|
|
...validSearchData,
|
|
workflowId: 'nonexistent-workflow',
|
|
}
|
|
|
|
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
|
|
allowed: false,
|
|
status: 404,
|
|
message: 'Workflow not found',
|
|
})
|
|
|
|
const req = createMockRequest('POST', workflowData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(data.error).toBe('Workflow not found')
|
|
})
|
|
|
|
it('should return not found for non-existent knowledge base', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: false,
|
|
notFound: true,
|
|
})
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(data.error).toBe('Knowledge base not found or access denied')
|
|
})
|
|
|
|
it('should return not found for some missing knowledge bases', async () => {
|
|
const multiKbData = {
|
|
...validSearchData,
|
|
knowledgeBaseIds: ['kb-123', 'kb-missing'],
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess
|
|
.mockResolvedValueOnce({ hasAccess: true, knowledgeBase: mockKnowledgeBases[0] })
|
|
.mockResolvedValueOnce({ hasAccess: false, notFound: true })
|
|
|
|
const req = createMockRequest('POST', multiKbData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(404)
|
|
expect(data.error).toBe('Knowledge bases not found or access denied: kb-missing')
|
|
})
|
|
|
|
it.concurrent('should validate search parameters', async () => {
|
|
const invalidData = {
|
|
knowledgeBaseIds: '', // Empty string
|
|
query: '', // Empty query
|
|
topK: 150, // Too high
|
|
}
|
|
|
|
const req = createMockRequest('POST', invalidData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(data.error).toBe('Validation error')
|
|
expect(data.details).toBeDefined()
|
|
})
|
|
|
|
it('should use default topK value when not provided', async () => {
|
|
const dataWithoutTopK = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: 'test search query',
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockSearchResults) // Search results
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', dataWithoutTopK)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.data.topK).toBe(10) // Default value
|
|
})
|
|
|
|
it.concurrent('should handle OpenAI API errors', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases)
|
|
|
|
mockGenerateSearchEmbedding.mockRejectedValueOnce(
|
|
new Error('OpenAI API error: 401 Unauthorized - Invalid API key')
|
|
)
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(data.error).toBe('Failed to perform vector search')
|
|
})
|
|
|
|
it.concurrent('should handle missing OpenAI API key', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases)
|
|
|
|
mockGenerateSearchEmbedding.mockRejectedValueOnce(new Error('OPENAI_API_KEY not configured'))
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(data.error).toBe('Failed to perform vector search')
|
|
})
|
|
|
|
it.concurrent('should handle database errors during search', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases)
|
|
|
|
mockHandleVectorOnlySearch.mockRejectedValueOnce(new Error('Database error'))
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(data.error).toBe('Failed to perform vector search')
|
|
})
|
|
|
|
it.concurrent('should handle invalid OpenAI response format', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockDbChain.limit.mockResolvedValueOnce(mockKnowledgeBases)
|
|
|
|
mockGenerateSearchEmbedding.mockRejectedValueOnce(
|
|
new Error('Invalid response format from OpenAI embeddings API')
|
|
)
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(500)
|
|
expect(data.error).toBe('Failed to perform vector search')
|
|
})
|
|
|
|
describe('Cost tracking', () => {
|
|
it.concurrent('should include cost information in successful search response', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
|
|
expect(data.data.cost).toBeDefined()
|
|
expect(data.data.cost.input).toBe(0.00001042)
|
|
expect(data.data.cost.output).toBe(0)
|
|
expect(data.data.cost.total).toBe(0.00001042)
|
|
expect(data.data.cost.tokens).toEqual({
|
|
prompt: 521,
|
|
completion: 0,
|
|
total: 521,
|
|
})
|
|
expect(data.data.cost.model).toBe('text-embedding-3-small')
|
|
expect(data.data.cost.pricing).toEqual({
|
|
input: 0.02,
|
|
output: 0,
|
|
updatedAt: '2025-07-10',
|
|
})
|
|
})
|
|
|
|
it('should call cost calculation functions with correct parameters', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', validSearchData)
|
|
await POST(req)
|
|
|
|
expect(estimateTokenCount).toHaveBeenCalledWith('test search query', 'openai')
|
|
|
|
expect(calculateCost).toHaveBeenCalledWith('text-embedding-3-small', 521, 0, false)
|
|
})
|
|
|
|
it('should handle cost calculation with different query lengths', async () => {
|
|
vi.mocked(estimateTokenCount).mockReturnValue({
|
|
count: 1042,
|
|
confidence: 'high',
|
|
provider: 'openai',
|
|
method: 'precise',
|
|
})
|
|
vi.mocked(calculateCost).mockReturnValue({
|
|
input: 0.00002084,
|
|
output: 0,
|
|
total: 0.00002084,
|
|
pricing: {
|
|
input: 0.02,
|
|
output: 0,
|
|
updatedAt: '2025-07-10',
|
|
},
|
|
})
|
|
|
|
const longQueryData = {
|
|
...validSearchData,
|
|
query:
|
|
'This is a much longer search query with many more tokens to test cost calculation accuracy',
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', longQueryData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.data.cost.input).toBe(0.00002084)
|
|
expect(data.data.cost.tokens.prompt).toBe(1042)
|
|
expect(calculateCost).toHaveBeenCalledWith('text-embedding-3-small', 1042, 0, false)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Optional Query Search', () => {
|
|
const mockTagDefinitions = [
|
|
{ tagSlot: 'tag1', displayName: 'category', fieldType: 'text' },
|
|
{ tagSlot: 'tag2', displayName: 'priority', fieldType: 'text' },
|
|
]
|
|
|
|
const mockTaggedResults = [
|
|
{
|
|
id: 'chunk-1',
|
|
content: 'Tagged content 1',
|
|
documentId: 'doc-1',
|
|
chunkIndex: 0,
|
|
tag1: 'api',
|
|
tag2: 'high',
|
|
distance: 0,
|
|
knowledgeBaseId: 'kb-123',
|
|
},
|
|
{
|
|
id: 'chunk-2',
|
|
content: 'Tagged content 2',
|
|
documentId: 'doc-2',
|
|
chunkIndex: 1,
|
|
tag1: 'docs',
|
|
tag2: 'medium',
|
|
distance: 0,
|
|
knowledgeBaseId: 'kb-123',
|
|
},
|
|
]
|
|
|
|
it('should perform tag-only search without query', async () => {
|
|
const tagOnlyData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
|
topK: 10,
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
|
|
|
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
|
|
|
const req = createMockRequest('POST', tagOnlyData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(2)
|
|
expect(data.data.results[0].similarity).toBe(1) // Perfect similarity for tag-only
|
|
expect(data.data.query).toBe('') // Empty query
|
|
expect(data.data.cost).toBeUndefined() // No cost for tag-only search
|
|
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding API call
|
|
expect(mockHandleTagOnlySearch).toHaveBeenCalledWith({
|
|
knowledgeBaseIds: ['kb-123'],
|
|
topK: 10,
|
|
structuredFilters: [
|
|
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
|
],
|
|
})
|
|
})
|
|
|
|
it('should perform query + tag combination search', async () => {
|
|
const combinedData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: 'test search',
|
|
tagFilters: [{ tagName: 'category', value: 'api', fieldType: 'text', operator: 'eq' }],
|
|
topK: 10,
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
|
|
|
mockHandleTagAndVectorSearch.mockResolvedValue(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', combinedData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(2)
|
|
expect(data.data.query).toBe('test search')
|
|
expect(data.data.cost).toBeDefined() // Cost included for vector search
|
|
expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called
|
|
expect(mockHandleTagAndVectorSearch).toHaveBeenCalledWith({
|
|
knowledgeBaseIds: ['kb-123'],
|
|
topK: 10,
|
|
structuredFilters: [
|
|
{ tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'api', valueTo: undefined },
|
|
],
|
|
queryVector: JSON.stringify(mockEmbedding),
|
|
distanceThreshold: 1, // Single KB uses threshold of 1.0
|
|
})
|
|
})
|
|
|
|
it('should validate that either query or filters are provided', async () => {
|
|
const emptyData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
topK: 10,
|
|
}
|
|
|
|
const req = createMockRequest('POST', emptyData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(data.error).toBe('Validation error')
|
|
expect(data.details).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message:
|
|
'Please provide either a search query or tag filters to search your knowledge base',
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('should validate that empty query with empty filters fails', async () => {
|
|
const emptyFiltersData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: '',
|
|
filters: {},
|
|
topK: 10,
|
|
}
|
|
|
|
const req = createMockRequest('POST', emptyFiltersData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(data.error).toBe('Validation error')
|
|
})
|
|
|
|
it('should handle empty tag values gracefully', async () => {
|
|
const emptyTagValueData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: '',
|
|
topK: 10,
|
|
}
|
|
|
|
const req = createMockRequest('POST', emptyTagValueData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(data.error).toBe('Validation error')
|
|
expect(data.details).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message:
|
|
'Please provide either a search query or tag filters to search your knowledge base',
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('should handle null values from frontend gracefully', async () => {
|
|
const nullValuesData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
topK: null,
|
|
query: null,
|
|
filters: null,
|
|
}
|
|
|
|
const req = createMockRequest('POST', nullValuesData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(data.error).toBe('Validation error')
|
|
expect(data.details).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
message:
|
|
'Please provide either a search query or tag filters to search your knowledge base',
|
|
}),
|
|
])
|
|
)
|
|
})
|
|
|
|
it('should perform query-only search (existing behavior)', async () => {
|
|
const queryOnlyData = {
|
|
knowledgeBaseIds: 'kb-123',
|
|
query: 'test search query',
|
|
topK: 10,
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockSearchResults)
|
|
|
|
mockFetch.mockResolvedValue({
|
|
ok: true,
|
|
json: () =>
|
|
Promise.resolve({
|
|
data: [{ embedding: mockEmbedding }],
|
|
}),
|
|
})
|
|
|
|
const req = createMockRequest('POST', queryOnlyData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(2)
|
|
expect(data.data.query).toBe('test search query')
|
|
expect(data.data.cost).toBeDefined() // Cost included for vector search
|
|
expect(mockGenerateSearchEmbedding).toHaveBeenCalled() // Embedding API called
|
|
})
|
|
|
|
it('should handle tag-only search with multiple knowledge bases', async () => {
|
|
const multiKbTagData = {
|
|
knowledgeBaseIds: ['kb-123', 'kb-456'],
|
|
tagFilters: [
|
|
{ tagName: 'category', value: 'docs', fieldType: 'text', operator: 'eq' },
|
|
{ tagName: 'priority', value: 'high', fieldType: 'text', operator: 'eq' },
|
|
],
|
|
topK: 10,
|
|
}
|
|
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
mockCheckKnowledgeBaseAccess
|
|
.mockResolvedValueOnce({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
.mockResolvedValueOnce({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-456',
|
|
userId: 'user-123',
|
|
name: 'Test KB 2',
|
|
embeddingModel: 'text-embedding-3-small',
|
|
},
|
|
})
|
|
|
|
mockGetDocumentTagDefinitions.mockResolvedValue(mockTagDefinitions)
|
|
|
|
mockHandleTagOnlySearch.mockResolvedValue(mockTaggedResults)
|
|
|
|
mockDbChain.limit.mockResolvedValueOnce(mockTagDefinitions)
|
|
|
|
const req = createMockRequest('POST', multiKbTagData)
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.knowledgeBaseIds).toEqual(['kb-123', 'kb-456'])
|
|
expect(mockGenerateSearchEmbedding).not.toHaveBeenCalled() // No embedding for tag-only
|
|
})
|
|
})
|
|
|
|
describe('Deleted document filtering', () => {
|
|
it('should exclude results from deleted documents in vector search', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
|
|
mockHandleVectorOnlySearch.mockResolvedValue([
|
|
{
|
|
id: 'chunk-1',
|
|
content: 'Content from active document',
|
|
documentId: 'doc-active',
|
|
chunkIndex: 0,
|
|
tag1: null,
|
|
tag2: null,
|
|
tag3: null,
|
|
tag4: null,
|
|
tag5: null,
|
|
tag6: null,
|
|
tag7: null,
|
|
distance: 0.2,
|
|
knowledgeBaseId: 'kb-123',
|
|
},
|
|
])
|
|
|
|
mockGetQueryStrategy.mockReturnValue({
|
|
useParallel: false,
|
|
distanceThreshold: 1.0,
|
|
parallelLimit: 15,
|
|
singleQueryOptimized: true,
|
|
})
|
|
|
|
mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false })
|
|
mockGetDocumentMetadataByIds.mockResolvedValue({
|
|
'doc-active': {
|
|
filename: 'Active Document.pdf',
|
|
sourceUrl: 'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345',
|
|
},
|
|
})
|
|
|
|
const mockTagDefs = {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockResolvedValue([]),
|
|
}
|
|
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
|
|
|
const req = createMockRequest('POST', {
|
|
knowledgeBaseIds: ['kb-123'],
|
|
query: 'test query',
|
|
topK: 10,
|
|
})
|
|
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(1)
|
|
expect(data.data.results[0].documentId).toBe('doc-active')
|
|
expect(data.data.results[0].documentName).toBe('Active Document.pdf')
|
|
expect(data.data.results[0].sourceUrl).toBe(
|
|
'https://example.atlassian.net/wiki/spaces/DOCS/pages/12345'
|
|
)
|
|
})
|
|
|
|
it('should exclude results from deleted documents in tag search', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
|
|
mockGetDocumentTagDefinitions.mockResolvedValue([
|
|
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
|
])
|
|
|
|
mockHandleTagOnlySearch.mockResolvedValue([
|
|
{
|
|
id: 'chunk-2',
|
|
content: 'Content from active document with tag',
|
|
documentId: 'doc-active-tagged',
|
|
chunkIndex: 0,
|
|
tag1: 'api',
|
|
tag2: null,
|
|
tag3: null,
|
|
tag4: null,
|
|
tag5: null,
|
|
tag6: null,
|
|
tag7: null,
|
|
distance: 0,
|
|
knowledgeBaseId: 'kb-123',
|
|
},
|
|
])
|
|
|
|
mockGetQueryStrategy.mockReturnValue({
|
|
useParallel: false,
|
|
distanceThreshold: 1.0,
|
|
parallelLimit: 15,
|
|
singleQueryOptimized: true,
|
|
})
|
|
|
|
mockGetDocumentMetadataByIds.mockResolvedValue({
|
|
'doc-active-tagged': { filename: 'Active Tagged Document.pdf', sourceUrl: null },
|
|
})
|
|
|
|
const mockTagDefs = {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi
|
|
.fn()
|
|
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
|
}
|
|
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
|
|
|
const req = createMockRequest('POST', {
|
|
knowledgeBaseIds: ['kb-123'],
|
|
tagFilters: [{ tagName: 'tag1', value: 'api', fieldType: 'text', operator: 'eq' }],
|
|
topK: 10,
|
|
})
|
|
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(1)
|
|
expect(data.data.results[0].documentId).toBe('doc-active-tagged')
|
|
expect(data.data.results[0].documentName).toBe('Active Tagged Document.pdf')
|
|
expect(data.data.results[0].metadata).toEqual({ tag1: 'api' })
|
|
})
|
|
|
|
it('should exclude results from deleted documents in combined tag+vector search', async () => {
|
|
mockGetUserId.mockResolvedValue('user-123')
|
|
|
|
mockCheckKnowledgeBaseAccess.mockResolvedValue({
|
|
hasAccess: true,
|
|
knowledgeBase: {
|
|
id: 'kb-123',
|
|
userId: 'user-123',
|
|
name: 'Test KB',
|
|
deletedAt: null,
|
|
},
|
|
})
|
|
|
|
mockGetDocumentTagDefinitions.mockResolvedValue([
|
|
{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' },
|
|
])
|
|
|
|
mockHandleTagAndVectorSearch.mockResolvedValue([
|
|
{
|
|
id: 'chunk-3',
|
|
content: 'Relevant content from active document',
|
|
documentId: 'doc-active-combined',
|
|
chunkIndex: 0,
|
|
tag1: 'guide',
|
|
tag2: null,
|
|
tag3: null,
|
|
tag4: null,
|
|
tag5: null,
|
|
tag6: null,
|
|
tag7: null,
|
|
distance: 0.15,
|
|
knowledgeBaseId: 'kb-123',
|
|
},
|
|
])
|
|
|
|
mockGetQueryStrategy.mockReturnValue({
|
|
useParallel: false,
|
|
distanceThreshold: 1.0,
|
|
parallelLimit: 15,
|
|
singleQueryOptimized: true,
|
|
})
|
|
|
|
mockGenerateSearchEmbedding.mockResolvedValue({ embedding: [0.1, 0.2, 0.3], isBYOK: false })
|
|
mockGetDocumentMetadataByIds.mockResolvedValue({
|
|
'doc-active-combined': { filename: 'Active Combined Search.pdf', sourceUrl: null },
|
|
})
|
|
|
|
const mockTagDefs = {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi
|
|
.fn()
|
|
.mockResolvedValue([{ tagSlot: 'tag1', displayName: 'tag1', fieldType: 'text' }]),
|
|
}
|
|
mockDbChain.select.mockReturnValueOnce(mockTagDefs)
|
|
|
|
const req = createMockRequest('POST', {
|
|
knowledgeBaseIds: ['kb-123'],
|
|
query: 'relevant content',
|
|
tagFilters: [{ tagName: 'tag1', value: 'guide', fieldType: 'text', operator: 'eq' }],
|
|
topK: 10,
|
|
})
|
|
|
|
const response = await POST(req)
|
|
const data = await response.json()
|
|
|
|
expect(response.status).toBe(200)
|
|
expect(data.success).toBe(true)
|
|
expect(data.data.results).toHaveLength(1)
|
|
expect(data.data.results[0].documentId).toBe('doc-active-combined')
|
|
expect(data.data.results[0].documentName).toBe('Active Combined Search.pdf')
|
|
expect(data.data.results[0].metadata).toEqual({ tag1: 'guide' })
|
|
expect(data.data.results[0].similarity).toBe(0.85) // 1 - 0.15 distance
|
|
})
|
|
})
|
|
})
|