""" Tests for BaseSearchEngine helper methods. Tests cover: - _resolve_api_key() - API key resolution from multiple sources - _is_rate_limit_error() - Rate limit error detection - _get_full_content() - Default full content extraction - _sanitize_error_message() - Error message sanitization - _mask_api_key() - API key masking for safe logging """ import copy from unittest.mock import Mock, patch import pytest from src.local_deep_research.web_search_engines.search_engine_base import ( BaseSearchEngine, ) from src.local_deep_research.web_search_engines.rate_limiting import ( RateLimitError, ) class ConcreteSearchEngine(BaseSearchEngine): """Concrete implementation for testing.""" is_public = True is_generic = True def __init__(self, **kwargs): super().__init__(**kwargs) def _get_previews(self, query): return [] def _get_full_content(self, relevant_items): return super()._get_full_content(relevant_items) class TestIsValidApiKey: """Tests for _is_valid_api_key static method.""" def test_valid_api_key_returns_true(self): """Valid API key returns True.""" assert BaseSearchEngine._is_valid_api_key("sk-abc123def456") is True def test_empty_string_returns_false(self): """Empty string returns False.""" assert BaseSearchEngine._is_valid_api_key("") is False def test_none_returns_false(self): """None returns False.""" assert BaseSearchEngine._is_valid_api_key(None) is False def test_placeholder_your_api_key_here(self): """YOUR_API_KEY_HERE placeholder returns False.""" assert BaseSearchEngine._is_valid_api_key("YOUR_API_KEY_HERE") is False def test_placeholder_none_string(self): """'None' string returns False.""" assert BaseSearchEngine._is_valid_api_key("None") is False def test_placeholder_null_string(self): """'null' string returns False.""" assert BaseSearchEngine._is_valid_api_key("null") is False def test_placeholder_ends_with_api_key(self): """Keys ending with _API_KEY are rejected.""" assert BaseSearchEngine._is_valid_api_key("BRAVE_API_KEY") is False def test_placeholder_starts_with_your(self): """Keys starting with YOUR_ are rejected.""" assert BaseSearchEngine._is_valid_api_key("YOUR_SECRET_KEY") is False def test_whitespace_only_returns_false(self): """Whitespace-only string returns False.""" assert BaseSearchEngine._is_valid_api_key(" ") is False def test_strips_whitespace(self): """Valid key with whitespace is accepted after stripping.""" assert BaseSearchEngine._is_valid_api_key(" sk-abc123 ") is True def test_angle_bracket_placeholder(self): """Angle bracket placeholders are rejected.""" assert BaseSearchEngine._is_valid_api_key("") is False def test_env_var_placeholder(self): """Environment variable placeholders are rejected.""" assert BaseSearchEngine._is_valid_api_key("${API_KEY}") is False class TestCleanResultUrl: """Tests for _clean_result_url static method.""" def test_strips_surrounding_whitespace(self): """Leading/trailing whitespace is stripped.""" assert ( BaseSearchEngine._clean_result_url(" https://example.com ") == "https://example.com" ) def test_strips_tabs_and_newlines(self): """Tabs and newlines (common in scraped hrefs) are stripped.""" assert ( BaseSearchEngine._clean_result_url("\thttps://example.com\n") == "https://example.com" ) def test_clean_url_unchanged(self): """An already-clean URL passes through unchanged.""" assert ( BaseSearchEngine._clean_result_url("https://example.com") == "https://example.com" ) def test_none_returns_empty_string(self): """None coerces to '' (does not raise, does not become 'None').""" assert BaseSearchEngine._clean_result_url(None) == "" def test_empty_string_returns_empty(self): """Empty string returns empty string.""" assert BaseSearchEngine._clean_result_url("") == "" def test_whitespace_only_returns_empty(self): """Whitespace-only value strips down to empty string.""" assert BaseSearchEngine._clean_result_url(" ") == "" def test_non_str_value_is_coerced(self): """Non-str truthy values (e.g. a parsed href object) are str()-ified.""" class HrefLike: def __str__(self): return " https://example.com " assert ( BaseSearchEngine._clean_result_url(HrefLike()) == "https://example.com" ) class TestResolveApiKey: """Tests for _resolve_api_key method.""" def test_returns_provided_key(self): """Returns the provided API key when valid.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._resolve_api_key( api_key="sk-direct-key", setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-direct-key" def test_falls_back_to_settings(self): """Falls back to settings snapshot when no direct key.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "sk-settings-key"}, programmatic_mode=True, ) result = engine._resolve_api_key( api_key=None, setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-settings-key" def test_priority_order(self): """Direct parameter takes priority over settings.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "sk-settings-key"}, programmatic_mode=True, ) result = engine._resolve_api_key( api_key="sk-direct-key", setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-direct-key" def test_rejects_placeholder_in_direct_key(self): """Rejects placeholder values in direct parameter.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "sk-settings-key"}, programmatic_mode=True, ) result = engine._resolve_api_key( api_key="YOUR_API_KEY_HERE", setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-settings-key" def test_raises_value_error_when_not_found(self): """Raises ValueError when no valid key found.""" engine = ConcreteSearchEngine(programmatic_mode=True) with pytest.raises(ValueError) as exc_info: engine._resolve_api_key( api_key=None, setting_key="search.nonexistent_key", engine_name="Test Engine", ) assert "No valid API key found" in str(exc_info.value) assert "Test Engine" in str(exc_info.value) def test_handles_empty_string(self): """Empty string is treated as no key provided.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "sk-settings-key"}, programmatic_mode=True, ) result = engine._resolve_api_key( api_key="", setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-settings-key" def test_strips_whitespace_from_key(self): """Strips whitespace from resolved key.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._resolve_api_key( api_key=" sk-whitespace-key ", setting_key="search.api_key", engine_name="Test Engine", ) assert result == "sk-whitespace-key" def test_uses_instance_settings_snapshot(self): """Uses self.settings_snapshot when snapshot not provided.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "sk-instance-key"}, programmatic_mode=True, ) result = engine._resolve_api_key( api_key=None, setting_key="search.api_key", engine_name="Test Engine", settings_snapshot=None, # Should use self.settings_snapshot ) assert result == "sk-instance-key" class TestIsRateLimitError: """Tests for _is_rate_limit_error method.""" def test_detects_429_status_code(self): """Detects 429 HTTP status code.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error(429) is True def test_429_alone_does_not_match(self): """Bare '429' in a string does not trigger false positive.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error("Error code 429") is False # But '429' combined with a real phrase still matches assert ( engine._is_rate_limit_error("Error 429: Too Many Requests") is True ) def test_detects_rate_limit_phrases(self): """Detects common rate limit phrases.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error("Rate limit exceeded") is True assert engine._is_rate_limit_error("rate_limit hit") is True assert engine._is_rate_limit_error("You hit the ratelimit") is True def test_case_insensitive(self): """Detection is case insensitive.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error("RATE LIMIT EXCEEDED") is True assert engine._is_rate_limit_error("Rate Limit Exceeded") is True def test_detects_httperror_with_429(self): """Detects HTTPError with 429 status code.""" engine = ConcreteSearchEngine(programmatic_mode=True) class MockHTTPError(Exception): def __init__(self): super().__init__("HTTP Error") self.response = Mock(status_code=429) error = MockHTTPError() assert engine._is_rate_limit_error(error) is True def test_additional_patterns(self): """Additional patterns are matched.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert ( engine._is_rate_limit_error( "Custom error", additional_patterns={"custom error"} ) is True ) def test_detects_throttling(self): """Detects throttling messages.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error("Request throttled") is True assert engine._is_rate_limit_error("Throttling active") is True def test_non_rate_limit_error(self): """Non-rate-limit errors return False.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error(ValueError("Invalid input")) is False assert engine._is_rate_limit_error("Connection refused") is False assert engine._is_rate_limit_error(500) is False def test_detects_quota_exceeded(self): """Detects quota exceeded messages.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert engine._is_rate_limit_error("Quota exceeded for today") is True assert engine._is_rate_limit_error("API quota_exceeded") is True def test_detects_too_many_requests(self): """Detects 'too many requests' phrase.""" engine = ConcreteSearchEngine(programmatic_mode=True) assert ( engine._is_rate_limit_error("Too many requests, please slow down") is True ) class TestRaiseIfRateLimit: """Tests for _raise_if_rate_limit method.""" def test_raises_rate_limit_error_when_detected(self): """Raises RateLimitError when rate limit detected.""" engine = ConcreteSearchEngine(programmatic_mode=True) with pytest.raises(RateLimitError): engine._raise_if_rate_limit(429) def test_does_not_raise_for_non_rate_limit(self): """Does not raise for non-rate-limit errors.""" engine = ConcreteSearchEngine(programmatic_mode=True) # Should not raise engine._raise_if_rate_limit("Connection refused") engine._raise_if_rate_limit(ValueError("Invalid input")) def test_sanitizes_error_message(self): """Error message is sanitized before raising.""" engine = ConcreteSearchEngine(programmatic_mode=True) with pytest.raises(RateLimitError) as exc_info: engine._raise_if_rate_limit( "Rate limit with key sk-abcd1234efgh5678ijkl" ) # The API key should be redacted assert "sk-abcd1234" not in str(exc_info.value) class TestGetFullContentDefault: """Tests for default _get_full_content implementation.""" def test_empty_input(self): """Empty input returns empty list.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._get_full_content([]) assert result == [] def test_extracts_full_result(self): """Extracts data from _full_result key.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ { "title": "Preview", "_full_result": {"title": "Full", "content": "Content"}, } ] result = engine._get_full_content(items) assert len(result) == 1 assert result[0]["title"] == "Full" assert result[0]["content"] == "Content" def test_uses_item_when_no_full_result(self): """Uses item directly when no _full_result key.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [{"title": "Preview", "snippet": "Snippet"}] result = engine._get_full_content(items) assert len(result) == 1 assert result[0]["title"] == "Preview" assert result[0]["snippet"] == "Snippet" def test_removes_internal_key(self): """Removes _full_result key from output.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ { "title": "Preview", "_full_result": {"title": "Full", "content": "Content"}, } ] result = engine._get_full_content(items) assert "_full_result" not in result[0] def test_handles_nested_data(self): """Handles nested data structures.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ { "title": "Preview", "_full_result": { "title": "Full", "metadata": {"author": "Test", "date": "2024-01-01"}, }, } ] result = engine._get_full_content(items) assert result[0]["metadata"]["author"] == "Test" def test_multiple_items(self): """Handles multiple items correctly.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ {"title": "A", "_full_result": {"title": "A Full"}}, {"title": "B", "_full_result": {"title": "B Full"}}, {"title": "C"}, # No _full_result ] result = engine._get_full_content(items) assert len(result) == 3 assert result[0]["title"] == "A Full" assert result[1]["title"] == "B Full" assert result[2]["title"] == "C" class TestSanitizeErrorMessage: """Tests for _sanitize_error_message method.""" def test_masks_bearer_tokens(self): """Masks Bearer tokens.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Error with Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" ) assert "Bearer [REDACTED]" in result assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in result def test_masks_api_keys_in_urls(self): """Masks API keys in URL parameters.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Failed to call https://api.example.com?api_key=secret123" ) assert "api_key=[REDACTED]" in result assert "secret123" not in result def test_masks_url_credentials(self): """Masks credentials in URLs.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Error connecting to https://user:password@api.example.com" ) assert "[REDACTED]:[REDACTED]@" in result assert "user" not in result or result.count("user") == 0 assert "password" not in result def test_preserves_safe_content(self): """Preserves non-sensitive content.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Connection timeout after 30 seconds" ) assert result == "Connection timeout after 30 seconds" def test_masks_sk_prefixed_keys(self): """Masks sk- prefixed API keys.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Invalid API key: sk-abcdefghijklmnop1234567890" ) assert "[REDACTED_KEY]" in result assert "sk-abcdefghijklmnop1234567890" not in result def test_masks_pk_prefixed_keys(self): """Masks pk- prefixed API keys.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Invalid key: pk-test123456789012345678" ) assert "[REDACTED_KEY]" in result def test_empty_message(self): """Empty message returns empty.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message("") assert result == "" def test_none_message(self): """None message returns None.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message(None) assert result is None class TestScrubError: """Tests for _scrub_error — the dual-scrub helper (regex + literals). This is the single chokepoint every engine catch site now routes through, so its contract is what prevents the per-site drift that previously dropped a secret (e.g. an Elasticsearch password). """ def test_runs_regex_sanitize_pass(self): """A foreign URL credential (no known literal) is caught by regex.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._scrub_error( Exception("connect failed: https://user:s3cr3tpass@host/x") ) assert "s3cr3tpass" not in result assert "[REDACTED]:[REDACTED]@" in result def test_redacts_own_api_key_literal(self): """The engine's own api_key is redacted even when its shape matches no regex pattern — i.e. the literal pass runs too.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.api_key = "plain-opaque-key-1234567890" result = engine._scrub_error( Exception("auth failed for key plain-opaque-key-1234567890") ) assert "plain-opaque-key-1234567890" not in result def test_redacts_all_secret_attrs(self): """An engine declaring multiple _secret_attrs redacts every one. Regression guard for the drift class: if _secret_attrs (or a hand- copied call site) omitted ``_password``, it would leak here. """ class MultiSecretEngine(ConcreteSearchEngine): _secret_attrs = ("_api_key", "_password") engine = MultiSecretEngine(programmatic_mode=True) engine._api_key = "the-api-key-aaaaaaaaaaaa" engine._password = "the-password-bbbbbbbbbbbb" result = engine._scrub_error( Exception( "fail key=the-api-key-aaaaaaaaaaaa pw=the-password-bbbbbbbbbbbb" ) ) assert "the-api-key-aaaaaaaaaaaa" not in result assert "the-password-bbbbbbbbbbbb" not in result def test_accepts_string_input(self): """Accepts a prebuilt message string, not just an exception.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._scrub_error( "Rate limited: sk-abcdefghijklmnop1234567890" ) assert "sk-abcdefghijklmnop1234567890" not in result def test_empty_secret_attrs_still_runs_regex(self): """With no declared literal secrets, the regex pass still applies and no AttributeError/TypeError escapes (missing attrs resolve to None, which redact_secrets skips).""" class NoSecretEngine(ConcreteSearchEngine): _secret_attrs = () engine = NoSecretEngine(programmatic_mode=True) result = engine._scrub_error( Exception("boom at https://user:s3cr3tpass@host/x") ) assert "s3cr3tpass" not in result assert "[REDACTED]:[REDACTED]@" in result def test_non_string_secret_attr_does_not_crash(self): """A misconfigured non-string secret (e.g. an int from settings) must not raise inside _scrub_error (which runs in except blocks); it is coerced to str and still redacted.""" engine = ConcreteSearchEngine(programmatic_mode=True) engine.api_key = 1234567890123 # non-string, >=8 chars when stringified result = engine._scrub_error( Exception("auth failed for key 1234567890123") ) assert "1234567890123" not in result def test_exception_with_raising_str_does_not_crash(self): """An exception whose __str__ raises must not crash the handler.""" class Nasty(Exception): def __str__(self): raise RuntimeError("str boom") engine = ConcreteSearchEngine(programmatic_mode=True) # Must not raise; returns a safe placeholder rendering. result = engine._scrub_error(Nasty()) assert isinstance(result, str) assert "unprintable" in result class TestMaskApiKey: """Tests for _mask_api_key method.""" def test_masks_long_key(self): """Masks long API key with visible chars at start and end.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key("sk-abcdefghijklmnop123456") assert result == "sk-a...3456" assert "bcdefghijklmnop12" not in result def test_short_key_returns_stars(self): """Short key returns ***.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key("short") assert result == "***" def test_empty_key_returns_stars(self): """Empty key returns ***.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key("") assert result == "***" def test_none_key_returns_stars(self): """None key returns ***.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key(None) assert result == "***" def test_custom_visible_chars(self): """Custom visible_chars parameter works.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key( "sk-abcdefghijklmnop123456", visible_chars=6 ) assert result == "sk-abc...123456" def test_exact_boundary_length(self): """Key at exactly visible_chars * 2 boundary.""" engine = ConcreteSearchEngine(programmatic_mode=True) # 8 chars total, visible_chars=4 means 8 chars shown, so boundary case result = engine._mask_api_key("12345678", visible_chars=4) assert result == "***" def test_just_over_boundary(self): """Key just over boundary gets masked.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._mask_api_key("123456789", visible_chars=4) assert result == "1234...6789" class TestRateLimitPatterns: """Tests for rate_limit_patterns class attribute.""" def test_default_patterns_exist(self): """Default patterns are defined.""" assert "rate limit" in BaseSearchEngine.rate_limit_patterns assert "throttl" in BaseSearchEngine.rate_limit_patterns assert "too many requests" in BaseSearchEngine.rate_limit_patterns def test_subclass_can_extend_patterns(self): """Subclass can extend patterns.""" class CustomEngine(BaseSearchEngine): rate_limit_patterns = BaseSearchEngine.rate_limit_patterns | { "custom limit", "engine specific", } def _get_previews(self, query): return [] assert "custom limit" in CustomEngine.rate_limit_patterns assert "engine specific" in CustomEngine.rate_limit_patterns # Original patterns still present assert "rate limit" in CustomEngine.rate_limit_patterns def test_patterns_are_case_insensitive_in_matching(self): """Pattern matching is case insensitive.""" engine = ConcreteSearchEngine(programmatic_mode=True) # All these should match due to case-insensitive comparison assert engine._is_rate_limit_error("RATE LIMIT") is True assert engine._is_rate_limit_error("Rate Limit") is True assert engine._is_rate_limit_error("rate limit") is True class TestExtractFullResult: """Tests for _extract_full_result helper method.""" def test_extracts_from_full_result(self): """Extracts data from _full_result key.""" engine = ConcreteSearchEngine(programmatic_mode=True) item = { "title": "Preview", "_full_result": {"title": "Full Title", "content": "Full Content"}, } result = engine._extract_full_result(item) assert result["title"] == "Full Title" assert result["content"] == "Full Content" def test_uses_item_when_no_full_result(self): """Uses item directly when no _full_result.""" engine = ConcreteSearchEngine(programmatic_mode=True) item = {"title": "Preview", "snippet": "Snippet"} result = engine._extract_full_result(item) assert result["title"] == "Preview" assert result["snippet"] == "Snippet" def test_removes_full_result_key(self): """Removes _full_result from output.""" engine = ConcreteSearchEngine(programmatic_mode=True) item = { "title": "Preview", "_full_result": {"title": "Full", "content": "Content"}, } result = engine._extract_full_result(item) assert "_full_result" not in result def test_preserves_all_other_keys(self): """Preserves all keys from source.""" engine = ConcreteSearchEngine(programmatic_mode=True) item = { "title": "Preview", "_full_result": { "title": "Full", "content": "Content", "url": "https://example.com", "metadata": {"key": "value"}, }, } result = engine._extract_full_result(item) assert "url" in result assert "metadata" in result assert result["metadata"]["key"] == "value" # ── Additional coverage tests ────────────────────────────────────────── class TestRaiseIfRateLimitExtended: """Extended tests for _raise_if_rate_limit — sanitization and edge cases.""" def test_sanitizes_api_key_in_error_message(self): """API keys in error messages are redacted before raising.""" engine = ConcreteSearchEngine(programmatic_mode=True) api_key = "sk-abcdefghijklmnop12345678" with pytest.raises(RateLimitError) as exc_info: engine._raise_if_rate_limit(f"Rate limit with key {api_key}") assert api_key not in str(exc_info.value) assert "[REDACTED_KEY]" in str(exc_info.value) def test_passes_additional_patterns(self): """Additional patterns are forwarded to detection.""" engine = ConcreteSearchEngine(programmatic_mode=True) with pytest.raises(RateLimitError): engine._raise_if_rate_limit( "custom throttle triggered", additional_patterns={"custom throttle"}, ) def test_does_not_raise_for_non_rate_limit(self): """Non-rate-limit errors pass through without raising.""" engine = ConcreteSearchEngine(programmatic_mode=True) # Should return None, not raise result = engine._raise_if_rate_limit("Connection refused") assert result is None def test_handles_none_error(self): """None error doesn't crash.""" engine = ConcreteSearchEngine(programmatic_mode=True) # _is_rate_limit_error converts to string, "None" doesn't match any pattern result = engine._raise_if_rate_limit(None) assert result is None class TestSanitizeErrorMessageExtended: """Extended tests for _sanitize_error_message — regex gaps.""" def test_preserves_long_alphanumeric_string(self): """Non-secret 32+ char strings (UUIDs, request IDs) are preserved.""" engine = ConcreteSearchEngine(programmatic_mode=True) request_id = "a" * 32 result = engine._sanitize_error_message(f"Request {request_id} failed") assert request_id in result def test_redacts_multiple_url_params(self): """Multiple sensitive URL parameters are all redacted.""" engine = ConcreteSearchEngine(programmatic_mode=True) result = engine._sanitize_error_message( "Failed: https://api.example.com?api_key=secret1&token=secret2" ) assert "secret1" not in result assert "secret2" not in result assert "api_key=[REDACTED]" in result assert "token=[REDACTED]" in result def test_sk_key_boundary_20_chars(self): """sk- key with exactly 20 chars is redacted, 19 chars is not.""" engine = ConcreteSearchEngine(programmatic_mode=True) key_20 = "sk-" + "a" * 20 key_19 = "sk-" + "a" * 19 result_20 = engine._sanitize_error_message(f"key: {key_20}") result_19 = engine._sanitize_error_message(f"key: {key_19}") assert key_20 not in result_20 assert "[REDACTED_KEY]" in result_20 # 19-char key doesn't match the sk- pattern (requires 20+) assert key_19 in result_19 def test_multiple_patterns_in_one_message(self): """Bearer token, sk- key, and URL credentials all redacted together.""" engine = ConcreteSearchEngine(programmatic_mode=True) message = ( "Bearer eyJhbGciOiJIUzI1NiJ9 failed for " "https://user:pass@api.com with key " "sk-abcdefghijklmnop123456" ) result = engine._sanitize_error_message(message) assert "Bearer [REDACTED]" in result assert "[REDACTED]:[REDACTED]@" in result assert "sk-abcdefghijklmnop123456" not in result class TestIsRateLimitErrorExtended: """Extended tests for _is_rate_limit_error — direct status_code path.""" def test_direct_status_code_attribute_429(self): """Exception with status_code=429 directly (not on .response).""" engine = ConcreteSearchEngine(programmatic_mode=True) class DirectStatusError(Exception): status_code = 429 assert engine._is_rate_limit_error(DirectStatusError("error")) is True def test_direct_status_code_attribute_500(self): """Exception with status_code=500 returns False.""" engine = ConcreteSearchEngine(programmatic_mode=True) class DirectStatusError(Exception): status_code = 500 assert engine._is_rate_limit_error(DirectStatusError("error")) is False def test_status_code_none(self): """Exception with status_code=None doesn't crash.""" engine = ConcreteSearchEngine(programmatic_mode=True) class NoneStatusError(Exception): status_code = None assert engine._is_rate_limit_error(NoneStatusError("error")) is False class TestResolveApiKeyExtended: """Extended tests for _resolve_api_key — placeholder cascade.""" def test_settings_returns_placeholder_raises_error(self): """Settings returning a placeholder falls through to ValueError.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"}, programmatic_mode=True, ) with pytest.raises(ValueError) as exc_info: engine._resolve_api_key( api_key=None, setting_key="search.api_key", engine_name="Test Engine", ) assert "No valid API key found" in str(exc_info.value) def test_both_direct_and_settings_are_placeholders(self): """Both sources having placeholders raises ValueError.""" engine = ConcreteSearchEngine( settings_snapshot={"search.api_key": "YOUR_API_KEY_HERE"}, programmatic_mode=True, ) with pytest.raises(ValueError): engine._resolve_api_key( api_key="PLACEHOLDER", setting_key="search.api_key", engine_name="Test Engine", ) class TestGetFullContentExtended: """Extended tests for _get_full_content — mutation safety and edge cases.""" def test_does_not_mutate_input_dicts(self): """Input dicts remain unchanged after _get_full_content.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ { "title": "Preview", "_full_result": {"title": "Full", "content": "Content"}, } ] original = copy.deepcopy(items) engine._get_full_content(items) assert items == original def test_full_result_is_none(self): """Item with _full_result=None falls back to the item itself.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [{"_full_result": None, "title": "test"}] result = engine._get_full_content(items) assert len(result) == 1 assert result[0]["title"] == "test" assert "_full_result" not in result[0] def test_full_result_is_empty_dict(self): """Item with _full_result={} returns empty dict, does not fall back to item.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [{"_full_result": {}, "title": "test"}] result = engine._get_full_content(items) assert len(result) == 1 assert result[0] == {} def test_conflicting_keys_full_result_wins(self): """When _full_result has same key as item, _full_result's value is used.""" engine = ConcreteSearchEngine(programmatic_mode=True) items = [ { "title": "outer", "_full_result": { "title": "inner", "url": "https://example.com", }, } ] result = engine._get_full_content(items) assert result[0]["title"] == "inner" assert result[0]["url"] == "https://example.com" class TestRunIntegration: """Integration tests for BaseSearchEngine.run() method.""" def test_search_snippets_only_skips_get_full_content(self): """When search_snippets_only=True, _get_full_content is not called.""" engine = ConcreteSearchEngine( search_snippets_only=True, programmatic_mode=True, ) previews = [{"title": "Result 1", "snippet": "..."}] with patch.object(engine, "_get_previews", return_value=previews): with patch.object(engine, "_get_full_content") as mock_full: result = engine.run("test query") mock_full.assert_not_called() assert result == previews def test_search_snippets_only_false_calls_get_full_content(self): """When search_snippets_only=False, _get_full_content IS called.""" engine = ConcreteSearchEngine( search_snippets_only=False, programmatic_mode=True, ) previews = [{"title": "Result 1", "snippet": "..."}] full_content = [{"title": "Result 1", "content": "Full text"}] with patch.object(engine, "_get_previews", return_value=previews): with patch.object( engine, "_get_full_content", return_value=full_content ) as mock_full: result = engine.run("test query") mock_full.assert_called_once_with(previews) assert result == full_content def test_rate_limit_in_get_previews_returns_empty(self): """RateLimitError in _get_previews returns empty list.""" engine = ConcreteSearchEngine(programmatic_mode=True) with patch.object( engine, "_get_previews", side_effect=RateLimitError("rate limited") ): result = engine.run("test query") assert result == [] def test_non_rate_limit_exception_returns_empty(self): """Non-rate-limit exception in _get_previews returns empty list.""" engine = ConcreteSearchEngine(programmatic_mode=True) with patch.object( engine, "_get_previews", side_effect=ConnectionError("failed") ): result = engine.run("test query") assert result == []