# #region Test.LLMProvider [C:3] [TYPE Module] [SEMANTICS test,llm,provider,mask,encryption] # @BRIEF Tests for services/llm_provider.py — mask_api_key, is_masked_or_placeholder, LLMProviderService. # @RELATION BINDS_TO -> [llm_provider] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import os os.environ.setdefault("ENCRYPTION_KEY", "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=") from unittest.mock import MagicMock, patch import pytest class TestMaskApiKey: """mask_api_key — safe display of API keys.""" def test_none_returns_empty(self): from src.services.llm_provider import mask_api_key assert mask_api_key(None) == "" def test_empty_returns_empty(self): from src.services.llm_provider import mask_api_key assert mask_api_key("") == "" def test_short_key_returns_four_asterisks(self): from src.services.llm_provider import mask_api_key assert mask_api_key("abc") == "****" assert mask_api_key("abcd") == "****" def test_medium_key_shows_two_each_side(self): from src.services.llm_provider import mask_api_key result = mask_api_key("abcdefgh") # 8 chars assert result == "ab...gh" def test_long_key_shows_four_each_side(self): from src.services.llm_provider import mask_api_key result = mask_api_key("sk-1234567890abcdef") # 18 chars assert result == "sk-1...cdef" def test_key_length_five_shows_two_each_side(self): from src.services.llm_provider import mask_api_key result = mask_api_key("abcde") # 5 chars assert result == "ab...de" def test_key_length_nine_shows_four_each_side(self): from src.services.llm_provider import mask_api_key result = mask_api_key("abcdefghi") # 9 chars assert result == "abcd...fghi" class TestIsMaskedOrPlaceholder: """is_masked_or_placeholder — detect masked/placeholder keys.""" def test_none_returns_true(self): from src.services.llm_provider import is_masked_or_placeholder assert is_masked_or_placeholder(None) is True def test_empty_returns_true(self): from src.services.llm_provider import is_masked_or_placeholder assert is_masked_or_placeholder("") is True def test_placeholder_returns_true(self): from src.services.llm_provider import is_masked_or_placeholder assert is_masked_or_placeholder("********") is True def test_partial_mask_returns_true(self): from src.services.llm_provider import is_masked_or_placeholder assert is_masked_or_placeholder("sk-...abc") is True def test_real_key_returns_false(self): from src.services.llm_provider import is_masked_or_placeholder assert is_masked_or_placeholder("sk-real-key-12345") is False class TestLLMProviderService: """LLMProviderService — CRUD with encrypted API keys.""" @pytest.fixture def db(self): return MagicMock() @pytest.fixture def service(self, db): from src.services.llm_provider import LLMProviderService return LLMProviderService(db) def test_get_all_providers(self, service, db): from src.models.llm import LLMProvider expected = [MagicMock(spec=LLMProvider)] db.query.return_value.all.return_value = expected result = service.get_all_providers() assert result == expected db.query.assert_called_once_with(LLMProvider) def test_get_provider_found(self, service, db): from src.models.llm import LLMProvider expected = MagicMock(spec=LLMProvider) db.query.return_value.filter.return_value.first.return_value = expected result = service.get_provider("prov-1") assert result is expected db.query.assert_called_once_with(LLMProvider) def test_get_provider_not_found(self, service, db): db.query.return_value.filter.return_value.first.return_value = None result = service.get_provider("nonexistent") assert result is None def test_create_provider(self, service, db): from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider # Build a proper config mock with enum-like provider_type config = MagicMock() config.name = "GPT-4" config.provider_type = MagicMock() config.provider_type.value = "openai" config.api_key = "sk-real-key-12345" config.base_url = "https://api.openai.com" config.is_active = True config.is_multimodal = True config.default_model = "gpt-4" config.max_images = None config.context_window = 8192 config.max_output_tokens = 4096 with patch.object(service.encryption, "encrypt", return_value="encrypted-key"): with patch("src.services.llm_provider.LLMProvider") as mock_provider_cls: mock_provider = MagicMock(spec=LLMProvider) mock_provider_cls.return_value = mock_provider db.add.return_value = None db.commit.return_value = None db.refresh.return_value = None result = service.create_provider(config) assert result is mock_provider db.add.assert_called_once() def test_update_provider(self, service, db): from src.models.llm import LLMProvider existing = MagicMock(spec=LLMProvider) db.query.return_value.filter.return_value.first.return_value = existing config = MagicMock() config.name = "GPT-4 Turbo" config.provider_type = MagicMock() config.provider_type.value = "openai" config.api_key = "sk-new-key" config.base_url = "https://api.openai.com" config.is_active = True config.is_multimodal = True config.default_model = "gpt-4-turbo" config.max_images = None config.context_window = 128000 config.max_output_tokens = 4096 with patch.object(service.encryption, "encrypt", return_value="new-encrypted"): result = service.update_provider("prov-1", config) db.commit.assert_called_once() def test_delete_provider(self, service, db): from src.models.llm import LLMProvider existing = MagicMock(spec=LLMProvider) db.query.return_value.filter.return_value.first.return_value = existing result = service.delete_provider("prov-1") db.delete.assert_called_once_with(existing) db.commit.assert_called_once() assert result is True def test_delete_provider_not_found(self, service, db): db.query.return_value.filter.return_value.first.return_value = None result = service.delete_provider("nonexistent") assert result is False def test_set_max_images(self, service, db): from src.models.llm import LLMProvider existing = MagicMock(spec=LLMProvider) db.query.return_value.filter.return_value.first.return_value = existing result = service.set_max_images("prov-1", 5) assert existing.max_images == 5 db.commit.assert_called_once() assert result is existing def test_set_max_images_not_found(self, service, db): db.query.return_value.filter.return_value.first.return_value = None result = service.set_max_images("nonexistent", 5) assert result is None def test_get_decrypted_api_key(self, service, db): from src.models.llm import LLMProvider existing = MagicMock(spec=LLMProvider) existing.api_key_encrypted = "encrypted-value" db.query.return_value.filter.return_value.first.return_value = existing with patch.object(service.encryption, "decrypt", return_value="decrypted-key"): result = service.get_decrypted_api_key("prov-1") assert result == "decrypted-key" def test_get_decrypted_api_key_decryption_failure(self, service, db): from src.models.llm import LLMProvider existing = MagicMock(spec=LLMProvider) existing.api_key_encrypted = "corrupted" db.query.return_value.filter.return_value.first.return_value = existing with patch.object(service.encryption, "decrypt", side_effect=Exception("decrypt failed")): result = service.get_decrypted_api_key("prov-1") assert result is None def test_get_decrypted_api_key_not_found(self, service, db): db.query.return_value.filter.return_value.first.return_value = None result = service.get_decrypted_api_key("nonexistent") assert result is None def test_update_provider_skips_encryption_for_masked_key(self): """Cover line 151: masked keys skip encryption.""" from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider db = MagicMock() existing = MagicMock(spec=LLMProvider) db.query.return_value.filter.return_value.first.return_value = existing service = LLMProviderService(db) config = MagicMock() config.api_key = "********" # masked config.name = "Test" config.provider_type = MagicMock() config.provider_type.value = "test" config.base_url = "http://test" config.is_active = True config.is_multimodal = False config.default_model = None config.max_images = None config.context_window = None config.max_output_tokens = None with patch.object(service.encryption, "encrypt") as mock_encrypt: result = service.update_provider("prov-1", config) mock_encrypt.assert_not_called() db.commit.assert_called_once() class TestLLMProviderServiceAdditional: """Additional edge cases for LLMProviderService.""" def test_update_provider_not_found(self): """Line 145: update_provider returns None when provider not found.""" from src.services.llm_provider import LLMProviderService db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None svc = LLMProviderService(db) config = MagicMock() result = svc.update_provider("nonexistent", config) assert result is None def test_decrypt_invalid_tag(self): """Lines 226-230: InvalidTag during decryption returns None.""" from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider from cryptography.exceptions import InvalidTag db = MagicMock() existing = MagicMock(spec=LLMProvider) existing.api_key = "encrypted" db.query.return_value.filter.return_value.first.return_value = existing svc = LLMProviderService(db) with patch.object(svc.encryption, "decrypt", side_effect=InvalidTag): result = svc.get_decrypted_api_key("prov-1") assert result is None def test_decrypt_value_error(self): """Lines 232-236: ValueError during decryption returns None.""" from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider db = MagicMock() existing = MagicMock(spec=LLMProvider) existing.api_key = "bad-format" db.query.return_value.filter.return_value.first.return_value = existing svc = LLMProviderService(db) with patch.object(svc.encryption, "decrypt", side_effect=ValueError("bad format")): result = svc.get_decrypted_api_key("prov-1") assert result is None def test_get_provider_token_config_not_found(self): """Lines 254-257: not found returns None fallback dict.""" from src.services.llm_provider import LLMProviderService db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None svc = LLMProviderService(db) result = svc.get_provider_token_config("nonexistent") assert result == {"model": None, "context_window": None, "max_output_tokens": None} def test_get_provider_token_config_found(self): """Lines 257-261: found returns provider config.""" from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider db = MagicMock() existing = MagicMock(spec=LLMProvider) existing.default_model = "gpt-4" existing.context_window = 8192 existing.max_output_tokens = 4096 db.query.return_value.filter.return_value.first.return_value = existing svc = LLMProviderService(db) result = svc.get_provider_token_config("prov-1") assert result["model"] == "gpt-4" assert result["context_window"] == 8192 def test_get_provider_token_config_default_model_fallback(self): """Line 258: model falls back to gpt-4o-mini when None.""" from src.services.llm_provider import LLMProviderService from src.models.llm import LLMProvider db = MagicMock() existing = MagicMock(spec=LLMProvider) existing.default_model = None existing.context_window = None existing.max_output_tokens = None db.query.return_value.filter.return_value.first.return_value = existing svc = LLMProviderService(db) result = svc.get_provider_token_config("prov-1") assert result["model"] == "gpt-4o-mini" # #endregion Test.LLMProvider