|
4 | 4 | * |
5 | 5 | * @vitest-environment node |
6 | 6 | */ |
7 | | -import { describe, expect, it, vi } from 'vitest' |
| 7 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
8 | 8 |
|
9 | 9 | vi.mock('drizzle-orm') |
10 | | -vi.mock('@/lib/logs/console/logger') |
| 10 | +vi.mock('@/lib/logs/console/logger', () => ({ |
| 11 | + createLogger: vi.fn(() => ({ |
| 12 | + info: vi.fn(), |
| 13 | + debug: vi.fn(), |
| 14 | + warn: vi.fn(), |
| 15 | + error: vi.fn(), |
| 16 | + })), |
| 17 | +})) |
11 | 18 | vi.mock('@/db') |
| 19 | +vi.mock('@/lib/documents/utils', () => ({ |
| 20 | + retryWithExponentialBackoff: (fn: any) => fn(), |
| 21 | +})) |
12 | 22 |
|
13 | | -import { handleTagAndVectorSearch, handleTagOnlySearch, handleVectorOnlySearch } from './utils' |
| 23 | +vi.stubGlobal( |
| 24 | + 'fetch', |
| 25 | + vi.fn().mockResolvedValue({ |
| 26 | + ok: true, |
| 27 | + json: async () => ({ |
| 28 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 29 | + }), |
| 30 | + }) |
| 31 | +) |
| 32 | + |
| 33 | +vi.mock('@/lib/env', () => ({ |
| 34 | + env: {}, |
| 35 | + isTruthy: (value: string | boolean | number | undefined) => |
| 36 | + typeof value === 'string' ? value === 'true' || value === '1' : Boolean(value), |
| 37 | +})) |
| 38 | + |
| 39 | +import { |
| 40 | + generateSearchEmbedding, |
| 41 | + handleTagAndVectorSearch, |
| 42 | + handleTagOnlySearch, |
| 43 | + handleVectorOnlySearch, |
| 44 | +} from './utils' |
14 | 45 |
|
15 | 46 | describe('Knowledge Search Utils', () => { |
| 47 | + beforeEach(() => { |
| 48 | + vi.clearAllMocks() |
| 49 | + }) |
| 50 | + |
16 | 51 | describe('handleTagOnlySearch', () => { |
17 | 52 | it('should throw error when no filters provided', async () => { |
18 | 53 | const params = { |
@@ -140,4 +175,251 @@ describe('Knowledge Search Utils', () => { |
140 | 175 | expect(params.distanceThreshold).toBe(0.8) |
141 | 176 | }) |
142 | 177 | }) |
| 178 | + |
| 179 | + describe('generateSearchEmbedding', () => { |
| 180 | + it('should use Azure OpenAI when KB-specific config is provided', async () => { |
| 181 | + const { env } = await import('@/lib/env') |
| 182 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 183 | + Object.assign(env, { |
| 184 | + AZURE_OPENAI_API_KEY: 'test-azure-key', |
| 185 | + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', |
| 186 | + AZURE_OPENAI_API_VERSION: '2024-12-01-preview', |
| 187 | + KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002', |
| 188 | + OPENAI_API_KEY: 'test-openai-key', |
| 189 | + }) |
| 190 | + |
| 191 | + const fetchSpy = vi.mocked(fetch) |
| 192 | + fetchSpy.mockResolvedValueOnce({ |
| 193 | + ok: true, |
| 194 | + json: async () => ({ |
| 195 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 196 | + }), |
| 197 | + } as any) |
| 198 | + |
| 199 | + const result = await generateSearchEmbedding('test query') |
| 200 | + |
| 201 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 202 | + 'https://test.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings?api-version=2024-12-01-preview', |
| 203 | + expect.objectContaining({ |
| 204 | + headers: expect.objectContaining({ |
| 205 | + 'api-key': 'test-azure-key', |
| 206 | + }), |
| 207 | + }) |
| 208 | + ) |
| 209 | + expect(result).toEqual([0.1, 0.2, 0.3]) |
| 210 | + |
| 211 | + // Clean up |
| 212 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 213 | + }) |
| 214 | + |
| 215 | + it('should fallback to OpenAI when no KB Azure config provided', async () => { |
| 216 | + const { env } = await import('@/lib/env') |
| 217 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 218 | + Object.assign(env, { |
| 219 | + OPENAI_API_KEY: 'test-openai-key', |
| 220 | + }) |
| 221 | + |
| 222 | + const fetchSpy = vi.mocked(fetch) |
| 223 | + fetchSpy.mockResolvedValueOnce({ |
| 224 | + ok: true, |
| 225 | + json: async () => ({ |
| 226 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 227 | + }), |
| 228 | + } as any) |
| 229 | + |
| 230 | + const result = await generateSearchEmbedding('test query') |
| 231 | + |
| 232 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 233 | + 'https://api.openai.com/v1/embeddings', |
| 234 | + expect.objectContaining({ |
| 235 | + headers: expect.objectContaining({ |
| 236 | + Authorization: 'Bearer test-openai-key', |
| 237 | + }), |
| 238 | + }) |
| 239 | + ) |
| 240 | + expect(result).toEqual([0.1, 0.2, 0.3]) |
| 241 | + |
| 242 | + // Clean up |
| 243 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 244 | + }) |
| 245 | + |
| 246 | + it('should use default API version when not provided in Azure config', async () => { |
| 247 | + const { env } = await import('@/lib/env') |
| 248 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 249 | + Object.assign(env, { |
| 250 | + AZURE_OPENAI_API_KEY: 'test-azure-key', |
| 251 | + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', |
| 252 | + KB_OPENAI_MODEL_NAME: 'custom-embedding-model', |
| 253 | + OPENAI_API_KEY: 'test-openai-key', |
| 254 | + }) |
| 255 | + |
| 256 | + const fetchSpy = vi.mocked(fetch) |
| 257 | + fetchSpy.mockResolvedValueOnce({ |
| 258 | + ok: true, |
| 259 | + json: async () => ({ |
| 260 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 261 | + }), |
| 262 | + } as any) |
| 263 | + |
| 264 | + await generateSearchEmbedding('test query') |
| 265 | + |
| 266 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 267 | + expect.stringContaining('api-version='), |
| 268 | + expect.any(Object) |
| 269 | + ) |
| 270 | + |
| 271 | + // Clean up |
| 272 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 273 | + }) |
| 274 | + |
| 275 | + it('should use custom model name when provided in Azure config', async () => { |
| 276 | + const { env } = await import('@/lib/env') |
| 277 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 278 | + Object.assign(env, { |
| 279 | + AZURE_OPENAI_API_KEY: 'test-azure-key', |
| 280 | + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', |
| 281 | + AZURE_OPENAI_API_VERSION: '2024-12-01-preview', |
| 282 | + KB_OPENAI_MODEL_NAME: 'custom-embedding-model', |
| 283 | + OPENAI_API_KEY: 'test-openai-key', |
| 284 | + }) |
| 285 | + |
| 286 | + const fetchSpy = vi.mocked(fetch) |
| 287 | + fetchSpy.mockResolvedValueOnce({ |
| 288 | + ok: true, |
| 289 | + json: async () => ({ |
| 290 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 291 | + }), |
| 292 | + } as any) |
| 293 | + |
| 294 | + await generateSearchEmbedding('test query', 'text-embedding-3-small') |
| 295 | + |
| 296 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 297 | + 'https://test.openai.azure.com/openai/deployments/custom-embedding-model/embeddings?api-version=2024-12-01-preview', |
| 298 | + expect.any(Object) |
| 299 | + ) |
| 300 | + |
| 301 | + // Clean up |
| 302 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 303 | + }) |
| 304 | + |
| 305 | + it('should throw error when no API configuration provided', async () => { |
| 306 | + const { env } = await import('@/lib/env') |
| 307 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 308 | + |
| 309 | + await expect(generateSearchEmbedding('test query')).rejects.toThrow( |
| 310 | + 'Either OPENAI_API_KEY or Azure OpenAI configuration (AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT) must be configured' |
| 311 | + ) |
| 312 | + }) |
| 313 | + |
| 314 | + it('should handle Azure OpenAI API errors properly', async () => { |
| 315 | + const { env } = await import('@/lib/env') |
| 316 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 317 | + Object.assign(env, { |
| 318 | + AZURE_OPENAI_API_KEY: 'test-azure-key', |
| 319 | + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', |
| 320 | + AZURE_OPENAI_API_VERSION: '2024-12-01-preview', |
| 321 | + KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002', |
| 322 | + }) |
| 323 | + |
| 324 | + const fetchSpy = vi.mocked(fetch) |
| 325 | + fetchSpy.mockResolvedValueOnce({ |
| 326 | + ok: false, |
| 327 | + status: 404, |
| 328 | + statusText: 'Not Found', |
| 329 | + text: async () => 'Deployment not found', |
| 330 | + } as any) |
| 331 | + |
| 332 | + await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed') |
| 333 | + |
| 334 | + // Clean up |
| 335 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 336 | + }) |
| 337 | + |
| 338 | + it('should handle OpenAI API errors properly', async () => { |
| 339 | + const { env } = await import('@/lib/env') |
| 340 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 341 | + Object.assign(env, { |
| 342 | + OPENAI_API_KEY: 'test-openai-key', |
| 343 | + }) |
| 344 | + |
| 345 | + const fetchSpy = vi.mocked(fetch) |
| 346 | + fetchSpy.mockResolvedValueOnce({ |
| 347 | + ok: false, |
| 348 | + status: 429, |
| 349 | + statusText: 'Too Many Requests', |
| 350 | + text: async () => 'Rate limit exceeded', |
| 351 | + } as any) |
| 352 | + |
| 353 | + await expect(generateSearchEmbedding('test query')).rejects.toThrow('Embedding API failed') |
| 354 | + |
| 355 | + // Clean up |
| 356 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 357 | + }) |
| 358 | + |
| 359 | + it('should include correct request body for Azure OpenAI', async () => { |
| 360 | + const { env } = await import('@/lib/env') |
| 361 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 362 | + Object.assign(env, { |
| 363 | + AZURE_OPENAI_API_KEY: 'test-azure-key', |
| 364 | + AZURE_OPENAI_ENDPOINT: 'https://test.openai.azure.com', |
| 365 | + AZURE_OPENAI_API_VERSION: '2024-12-01-preview', |
| 366 | + KB_OPENAI_MODEL_NAME: 'text-embedding-ada-002', |
| 367 | + }) |
| 368 | + |
| 369 | + const fetchSpy = vi.mocked(fetch) |
| 370 | + fetchSpy.mockResolvedValueOnce({ |
| 371 | + ok: true, |
| 372 | + json: async () => ({ |
| 373 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 374 | + }), |
| 375 | + } as any) |
| 376 | + |
| 377 | + await generateSearchEmbedding('test query') |
| 378 | + |
| 379 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 380 | + expect.any(String), |
| 381 | + expect.objectContaining({ |
| 382 | + body: JSON.stringify({ |
| 383 | + input: ['test query'], |
| 384 | + encoding_format: 'float', |
| 385 | + }), |
| 386 | + }) |
| 387 | + ) |
| 388 | + |
| 389 | + // Clean up |
| 390 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 391 | + }) |
| 392 | + |
| 393 | + it('should include correct request body for OpenAI', async () => { |
| 394 | + const { env } = await import('@/lib/env') |
| 395 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 396 | + Object.assign(env, { |
| 397 | + OPENAI_API_KEY: 'test-openai-key', |
| 398 | + }) |
| 399 | + |
| 400 | + const fetchSpy = vi.mocked(fetch) |
| 401 | + fetchSpy.mockResolvedValueOnce({ |
| 402 | + ok: true, |
| 403 | + json: async () => ({ |
| 404 | + data: [{ embedding: [0.1, 0.2, 0.3] }], |
| 405 | + }), |
| 406 | + } as any) |
| 407 | + |
| 408 | + await generateSearchEmbedding('test query', 'text-embedding-3-small') |
| 409 | + |
| 410 | + expect(fetchSpy).toHaveBeenCalledWith( |
| 411 | + expect.any(String), |
| 412 | + expect.objectContaining({ |
| 413 | + body: JSON.stringify({ |
| 414 | + input: ['test query'], |
| 415 | + model: 'text-embedding-3-small', |
| 416 | + encoding_format: 'float', |
| 417 | + }), |
| 418 | + }) |
| 419 | + ) |
| 420 | + |
| 421 | + // Clean up |
| 422 | + Object.keys(env).forEach((key) => delete (env as any)[key]) |
| 423 | + }) |
| 424 | + }) |
143 | 425 | }) |
0 commit comments