| """ |
| Tests for embedding service to ensure no zero embeddings are returned. |
| |
| These tests verify that the embedding service raises appropriate exceptions |
| instead of returning zero embeddings, following the "fail fast and loud" principle. |
| """ |
|
|
| from unittest.mock import AsyncMock, MagicMock, Mock, patch |
|
|
| import openai |
| import pytest |
|
|
| from src.server.services.embeddings import EmbeddingBatchResult |
| from src.server.services.embeddings.embedding_exceptions import ( |
| EmbeddingAPIError, |
| EmbeddingQuotaExhaustedError, |
| EmbeddingRateLimitError, |
| ) |
| from src.server.services.embeddings.embedding_service import ( |
| create_embedding, |
| create_embeddings_batch, |
| ) |
|
|
|
|
| class TestNoZeroEmbeddings: |
| """Test that no zero embeddings are ever returned.""" |
|
|
| |
| |
|
|
| @pytest.mark.asyncio |
| async def test_async_quota_exhausted_returns_failure(self) -> None: |
| """Test that quota exhaustion returns failure result instead of zeros.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_client.embeddings.create.side_effect = openai.RateLimitError( |
| "insufficient_quota: You have exceeded your quota", response=Mock(), body=None |
| ) |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-3-small", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_BATCH_SIZE": "10"}), |
| ), |
| ): |
| |
| with pytest.raises(EmbeddingQuotaExhaustedError) as exc_info: |
| await create_embedding("test text") |
|
|
| assert "quota exhausted" in str(exc_info.value).lower() |
|
|
| @pytest.mark.asyncio |
| async def test_async_rate_limit_raises_exception(self) -> None: |
| """Test that rate limit errors raise exception after retries.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_client.embeddings.create.side_effect = openai.RateLimitError( |
| "rate_limit_exceeded: Too many requests", response=Mock(), body=None |
| ) |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-3-small", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_BATCH_SIZE": "10"}), |
| ), |
| ): |
| with pytest.raises(EmbeddingRateLimitError) as exc_info: |
| await create_embedding("test text") |
|
|
| assert "rate limit" in str(exc_info.value).lower() |
|
|
| @pytest.mark.asyncio |
| async def test_async_api_error_raises_exception(self) -> None: |
| """Test that API errors raise exception instead of returning zeros.""" |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_client.embeddings.create.side_effect = Exception("Network error") |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-3-small", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_BATCH_SIZE": "10"}), |
| ), |
| ): |
| with pytest.raises(EmbeddingAPIError) as exc_info: |
| await create_embedding("test text") |
|
|
| assert "failed to create embedding" in str(exc_info.value).lower() |
|
|
| @pytest.mark.asyncio |
| async def test_batch_handles_partial_failures(self) -> None: |
| """Test that batch processing can handle partial failures gracefully.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_response = Mock() |
| mock_response.data = [Mock(embedding=[0.1] * 768), Mock(embedding=[0.2] * 768)] |
|
|
| |
| mock_client.embeddings.create = AsyncMock( |
| side_effect=[ |
| mock_response, |
| Exception("API Error"), |
| ] |
| ) |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-ada-002", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_BATCH_SIZE": "2"}), |
| ), |
| ): |
| |
| texts = ["text1", "text2", "text3", "text4"] |
| result = await create_embeddings_batch(texts) |
|
|
| |
| assert isinstance(result, EmbeddingBatchResult) |
| assert result.success_count == 2 |
| assert result.failure_count == 2 |
| assert len(result.embeddings) == 2 |
| assert len(result.failed_items) == 2 |
|
|
| |
| for embedding in result.embeddings: |
| assert not all(v == 0.0 for v in embedding) |
|
|
| @pytest.mark.asyncio |
| async def test_configurable_embedding_dimensions(self) -> None: |
| """Test that embedding dimensions can be configured via settings.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_create = AsyncMock() |
| mock_client.embeddings.create = mock_create |
|
|
| |
| mock_response = Mock() |
| mock_response.data = [Mock(embedding=[0.1] * 3072)] |
| mock_create.return_value = mock_response |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-3-large", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_DIMENSIONS": "3072"}), |
| ), |
| ): |
| result = await create_embeddings_batch(["test text"]) |
|
|
| |
| mock_create.assert_called_once() |
| call_args = mock_create.call_args |
| assert call_args.kwargs["dimensions"] == 3072 |
|
|
| |
| assert result.success_count == 1 |
| assert len(result.embeddings[0]) == 3072 |
|
|
| @pytest.mark.asyncio |
| async def test_default_embedding_dimensions(self) -> None: |
| """Test that default dimensions (768) are used when not configured.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_create = AsyncMock() |
| mock_client.embeddings.create = mock_create |
|
|
| |
| mock_response = Mock() |
| mock_response.data = [Mock(embedding=[0.1] * 768)] |
| mock_create.return_value = mock_response |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-3-small", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={}), |
| ), |
| ): |
| result = await create_embeddings_batch(["test text"]) |
|
|
| |
| mock_create.assert_called_once() |
| call_args = mock_create.call_args |
| assert call_args.kwargs["dimensions"] == 768 |
|
|
| |
| assert result.success_count == 1 |
| assert len(result.embeddings[0]) == 768 |
|
|
| @pytest.mark.asyncio |
| async def test_batch_quota_exhausted_stops_process(self) -> None: |
| """Test that quota exhaustion stops processing remaining batches.""" |
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_client.embeddings.create.side_effect = openai.RateLimitError( |
| "insufficient_quota: Quota exceeded", response=Mock(), body=None |
| ) |
|
|
| primary_config = {"provider": "openai", "embedding_model": "text-embedding-ada-002", "api_key": "key-ok"} |
| mock_get_configs = AsyncMock(return_value=[primary_config]) |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with ( |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_embedding_provider_configs", |
| mock_get_configs, |
| ), |
| patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client), |
| patch( |
| "src.server.services.embeddings.batch_processor.credential_service.get_credentials_by_category", |
| AsyncMock(return_value={"EMBEDDING_BATCH_SIZE": "10"}), |
| ), |
| ): |
| texts = ["text1", "text2", "text3", "text4"] |
| result = await create_embeddings_batch(texts) |
|
|
| |
| assert result.success_count == 0 |
| assert result.failure_count == 4 |
| assert len(result.embeddings) == 0 |
| assert all("quota" in item["error"].lower() for item in result.failed_items) |
|
|
| @pytest.mark.asyncio |
| async def test_no_zero_vectors_in_results(self) -> None: |
| """Test that no function ever returns a zero vector [0.0] * 768.""" |
| |
|
|
| |
| def is_zero_embedding(value): |
| if not isinstance(value, list): |
| return False |
| if len(value) != 768: |
| return False |
| return all(v == 0.0 for v in value) |
|
|
| |
| test_text = "This is a test" |
|
|
| |
| mock_client = MagicMock() |
| mock_client.aclose = AsyncMock() |
| mock_client.embeddings.create.side_effect = Exception("Test error") |
| mock_create_client = AsyncMock(return_value=mock_client) |
|
|
| with patch("src.server.services.embeddings.batch_processor.create_embedding_client", mock_create_client): |
| result = await create_embeddings_batch([test_text]) |
| |
| assert isinstance(result, EmbeddingBatchResult) |
| assert len(result.embeddings) == 0 |
| assert result.failure_count == 1 |
| |
| for embedding in result.embeddings: |
| assert not is_zero_embedding(embedding) |
|
|
|
|
| class TestEmbeddingBatchResult: |
| """Test the EmbeddingBatchResult dataclass.""" |
|
|
| def test_batch_result_initialization(self) -> None: |
| """Test that EmbeddingBatchResult initializes correctly.""" |
| result = EmbeddingBatchResult() |
| assert result.success_count == 0 |
| assert result.failure_count == 0 |
| assert result.embeddings == [] |
| assert result.failed_items == [] |
| assert not result.has_failures |
|
|
| def test_batch_result_add_success(self) -> None: |
| """Test adding successful embeddings.""" |
| result = EmbeddingBatchResult() |
| embedding = [0.1] * 768 |
| text = "test text" |
|
|
| result.add_success(embedding, text) |
|
|
| assert result.success_count == 1 |
| assert result.failure_count == 0 |
| assert len(result.embeddings) == 1 |
| assert result.embeddings[0] == embedding |
| assert result.texts_processed[0] == text |
| assert not result.has_failures |
|
|
| def test_batch_result_add_failure(self) -> None: |
| """Test adding failed items.""" |
| result = EmbeddingBatchResult() |
| error = EmbeddingAPIError("Test error", text_preview="test") |
|
|
| result.add_failure("test text", error, batch_index=0) |
|
|
| assert result.success_count == 0 |
| assert result.failure_count == 1 |
| assert len(result.failed_items) == 1 |
| assert result.has_failures |
|
|
| failed_item = result.failed_items[0] |
| assert failed_item["error"] == "Test error" |
| assert failed_item["error_type"] == "EmbeddingAPIError" |
| |
| assert "batch_index" in failed_item |
|
|
| def test_batch_result_mixed_results(self) -> None: |
| """Test batch result with both successes and failures.""" |
| result = EmbeddingBatchResult() |
|
|
| |
| result.add_success([0.1] * 768, "text1") |
| result.add_success([0.2] * 768, "text2") |
|
|
| |
| result.add_failure("text3", Exception("Error 1"), 1) |
| result.add_failure("text4", Exception("Error 2"), 1) |
|
|
| assert result.success_count == 2 |
| assert result.failure_count == 2 |
| assert result.total_requested == 4 |
| assert result.has_failures |
| assert len(result.embeddings) == 2 |
| assert len(result.failed_items) == 2 |
|
|