from types import SimpleNamespace import pytest from app.api.v1 import settings as settings_api from app.api.v1.settings import ( WebSearchIntegrationUpdate, _build_web_search_payload, _mask_secret, _normalize_web_search_payload, _resolve_web_search_api_key, ) from app.services.ai_tools.schemas import WebSearchConfig, WebSearchProviderConfig from app.services.ai_tools.web_search import WebSearchClient from app.services.credential_guides import generate_credential_guide from app.services.location.llm_fallback import ( collect_llm_location_fallback_candidate, collect_location_search_evidence, ) from app.services.location.models import LocationQuery @pytest.fixture(autouse=True) def isolated_web_search_env_files(monkeypatch, tmp_path): env_file = tmp_path / ".env" monkeypatch.setattr(settings_api, "WEB_SEARCH_ENV_FILES", (env_file,)) return env_file def test_normalize_web_search_payload_adds_default_provider(): payload = _normalize_web_search_payload({}) assert payload["default_provider"] == "tavily" assert payload["providers"]["tavily"]["base_url"] == "https://api.tavily.com" def test_web_search_key_prefers_provider_env(isolated_web_search_env_files): isolated_web_search_env_files.write_text( "TAVILY_API_KEY=tavily-env-key\nWEB_SEARCH_API_KEY=generic-search-key\n", encoding="utf-8", ) value, source = _resolve_web_search_api_key("tavily", {"api_key": ""}) assert value == "tavily-env-key" assert source == "env_file" def test_web_search_generic_key_only_applies_to_default_provider(isolated_web_search_env_files): isolated_web_search_env_files.write_text( "WEB_SEARCH_API_KEY=generic-search-key\n", encoding="utf-8", ) default_value, default_source = _resolve_web_search_api_key("tavily", {"api_key": ""}, "tavily") other_value, other_source = _resolve_web_search_api_key("brave", {"api_key": ""}, "tavily") assert default_value == "generic-search-key" assert default_source == "env_file" assert other_value == "" assert other_source == "" def test_build_web_search_payload_keeps_saved_key_when_preview_submitted(): current = { "web_search": { "default_provider": "tavily", "providers": { "tavily": { "provider": "tavily", "api_key": "tvly-old-secret", }, }, } } update = WebSearchIntegrationUpdate( enabled=True, provider="tavily", base_url="https://api.tavily.com", api_key=_mask_secret("tvly-old-secret")["preview"], ) payload = _build_web_search_payload(current, update) assert payload["enabled"] is True assert payload["providers"]["tavily"]["api_key"] == "tvly-old-secret" def test_build_web_search_payload_saves_provider_without_changing_default(): current = { "web_search": { "default_provider": "tavily", "providers": { "tavily": { "provider": "tavily", "api_key": "tvly-key", }, "brave": { "provider": "brave", "api_key": "", }, }, } } update = WebSearchIntegrationUpdate( enabled=True, provider="brave", base_url="https://api.search.brave.com/res/v1/web/search", api_key="brave-new-key", ) payload = _build_web_search_payload(current, update) assert payload["default_provider"] == "tavily" assert payload["providers"]["brave"]["api_key"] == "brave-new-key" @pytest.mark.asyncio async def test_tavily_adapter_normalizes_results(monkeypatch): config = WebSearchConfig( enabled=True, default_provider="tavily", provider="tavily", providers={ "tavily": WebSearchProviderConfig( provider="tavily", base_url="https://api.tavily.com", api_key="key", ) }, ) client = WebSearchClient(config) async def fake_request_json(*args, **kwargs): return { "query": "Alem.Cloud", "results": [ { "title": "Alem.Cloud official", "url": "https://example.test/alem", "content": "Alem.Cloud is in Astana.", "score": 0.9, } ], } monkeypatch.setattr(client, "_request_json", fake_request_json) results = await client.search("Alem.Cloud") assert results[0].source_provider == "tavily" assert results[0].url == "https://example.test/alem" assert "Astana" in results[0].snippet @pytest.mark.asyncio async def test_searxng_adapter_allows_empty_api_key(monkeypatch): config = WebSearchConfig( enabled=True, default_provider="searxng", provider="searxng", providers={ "searxng": WebSearchProviderConfig( provider="searxng", base_url="http://localhost:8080", api_key="", ) }, ) client = WebSearchClient(config) async def fake_request_json(*args, **kwargs): return { "results": [ { "title": "TAIPEI-1", "url": "https://example.test/taipei", "content": "TAIPEI-1 is in Taipei.", "score": 2, "engine": "duckduckgo", } ], } monkeypatch.setattr(client, "_request_json", fake_request_json) results = await client.search("TAIPEI-1") assert results[0].source_provider == "searxng" assert results[0].metadata["engine"] == "duckduckgo" @pytest.mark.asyncio async def test_location_search_evidence_returns_failure_on_empty_results(monkeypatch): class EmptySearchClient: async def search(self, *args, **kwargs): return [] result = await collect_location_search_evidence( web_search_client=EmptySearchClient(), query=LocationQuery(name="TAIPEI-1", country="Taiwan"), entity_type="compute_center", ) assert result.evidence == [] assert "no usable" in result.failure_reason @pytest.mark.asyncio async def test_llm_location_fallback_skips_when_search_evidence_empty(): class ExplodingAIClient: async def analyze(self, *_args, **_kwargs): raise AssertionError("LLM should not be called without evidence") result = await collect_llm_location_fallback_candidate( provider_client=ExplodingAIClient(), query=LocationQuery(name="TAIPEI-1", country="Taiwan"), entity_type="compute_center", search_evidence=[], ) assert result.candidates == [] assert "no WebSearch evidence" in result.failure_reason @pytest.mark.asyncio async def test_credential_guide_keeps_default_without_search_evidence(): class EmptySearchClient: async def search(self, *args, **kwargs): return [] class ExplodingAIClient: async def analyze(self, *_args, **_kwargs): raise AssertionError("AI should not be called without search evidence") async def fake_get_store(_db): return None, {} import app.services.credential_guides as credential_guides original = credential_guides._get_guide_store credential_guides._get_guide_store = fake_get_store try: guide = await generate_credential_guide( object(), "barentswatch", ExplodingAIClient(), EmptySearchClient(), ) finally: credential_guides._get_guide_store = original assert guide["source"] == "default" assert guide["verification_status"] == "unverified_no_search_evidence" @pytest.mark.asyncio async def test_credential_guide_uses_search_evidence(monkeypatch): class SearchClient: async def search(self, *args, **kwargs): from app.services.ai_tools.schemas import SearchEvidence return [ SearchEvidence( title="Official docs", url="https://docs.example.test", snippet="Create an AIS client.", source_provider="tavily", ) ] class AIClient: async def analyze(self, payload): assert payload.context["search_evidence"] return SimpleNamespace(content="## Generated\n\nSources included.") saved = {} async def fake_get_store(_db): return None, saved async def fake_save(db, provider, title, markdown, **metadata): return { "provider": provider, "title": title, "markdown": markdown, "source": "ai", **metadata, } import app.services.credential_guides as credential_guides monkeypatch.setattr(credential_guides, "_get_guide_store", fake_get_store) monkeypatch.setattr(credential_guides, "save_credential_guide", fake_save) guide = await generate_credential_guide( object(), "barentswatch", AIClient(), SearchClient(), ) assert guide["source"] == "ai" assert guide["verification_status"] == "verified_with_search_evidence" assert guide["sources"][0]["url"] == "https://docs.example.test"