import importlib import json import sys from pathlib import Path from fastapi.testclient import TestClient ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) def load_app_module(monkeypatch, *, skip_model_load=True, ollama_model=None, service_token=None, external_ai_enabled=False): if skip_model_load: monkeypatch.setenv("AI_SERVICE_SKIP_MODEL_LOAD", "1") else: monkeypatch.delenv("AI_SERVICE_SKIP_MODEL_LOAD", raising=False) monkeypatch.delenv("AI_SERVICE_EAGER_MODEL_LOAD", raising=False) # Default to keyless so the existing suite is unaffected by a token in the dev shell. if service_token is None: monkeypatch.delenv("AI_SERVICE_TOKEN", raising=False) else: monkeypatch.setenv("AI_SERVICE_TOKEN", service_token) if ollama_model is None: monkeypatch.delenv("OLLAMA_MODEL", raising=False) else: monkeypatch.setenv("OLLAMA_MODEL", ollama_model) if external_ai_enabled: monkeypatch.setenv("EXTERNAL_AI_ENABLED", "true") else: monkeypatch.delenv("EXTERNAL_AI_ENABLED", raising=False) if "app" in sys.modules: del sys.modules["app"] module = importlib.import_module("app") return importlib.reload(module) def test_health_reports_runtime_without_ollama_and_without_forcing_model_load(monkeypatch): module = load_app_module(monkeypatch) client = TestClient(module.app) response = client.get("/health") assert response.status_code == 200 payload = response.json() assert payload["ok"] is True assert payload["device"] == "cpu" assert payload["model_loaded"] is False assert payload["model_disabled"] is True assert payload["summarize_available"] is False assert "disabled" in payload["model_load_error"].lower() assert payload["ollama_configured"] is False assert payload["ollama_model"] is None assert payload["ollama_installed_models"] == [] assert payload["ollama_loaded_models"] == [] def test_summarize_returns_503_with_explicit_reason_when_model_loading_is_disabled(monkeypatch): module = load_app_module(monkeypatch) client = TestClient(module.app) response = client.post("/summarize", json={"text": "Platform engineering role with APIs and Python experience."}) assert response.status_code == 503 payload = response.json() assert "disabled" in payload["detail"].lower() def test_health_reports_ollama_unreachable_when_configured_but_not_available(monkeypatch): module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") def boom(path: str): raise OSError("connection refused") monkeypatch.setattr(module, "_ollama_json", boom) client = TestClient(module.app) response = client.get("/health") assert response.status_code == 200 payload = response.json() assert payload["ollama_configured"] is True assert payload["ollama_reachable"] is False assert payload["ollama_model"] == "qwen2.5:7b" assert payload["ollama_model_available"] is False def test_rewrite_cv_returns_plain_rewritten_text(monkeypatch): module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") monkeypatch.setattr(module, "_ollama_generate_text", lambda prompt: "# Professional Summary\nBuilt resilient backend systems.\n\n# Skills\n- C#\n- .NET") client = TestClient(module.app) response = client.post("/cv/rewrite", json={ "instruction": "Rewrite this CV into a cleaner master CV.", "text": "Professional Summary\nBuilt backend systems.", "max_length": 220, "min_length": 80, }) assert response.status_code == 200 payload = response.json() assert payload["rewritten_text"].startswith("# Professional Summary") assert "Role summary:" not in payload["rewritten_text"] def test_classify_block_returns_structured_json(monkeypatch): module = load_app_module(monkeypatch) def fake_generate_json(prompt: str): assert "Senior Platform Engineer" in prompt return { "section": "Work Experience", "confidence": 0.91, "reason": "job block", "title": "Senior Platform Engineer", "company": "Atlas Systems", "location": "Oslo", "start": "2019", "end": "Present", "bullets": ["Built event-driven APIs and migration tooling."], "summary": [], "skills": ["Python", "SQL"], } monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json) client = TestClient(module.app) response = client.post("/cv/classify-block", json={"block": "Senior Platform Engineer at Atlas Systems, Oslo, 2019 - Present. Built event-driven APIs and migration tooling."}) assert response.status_code == 200 payload = response.json() assert payload["section"] == "Work Experience" assert payload["title"] == "Senior Platform Engineer" assert payload["company"] == "Atlas Systems" assert payload["bullets"] == ["Built event-driven APIs and migration tooling."] assert payload["summary"] == [] assert payload["skills"] == ["Python", "SQL"] def test_classify_block_supports_projects_section(monkeypatch): # Phase 2.1-b: Projects and Certifications are now valid classified sections so project blocks # (e.g. the benchmark CV's JobTrack/InboxIntel) are no longer dropped into "Other". module = load_app_module(monkeypatch) def fake_generate_json(prompt: str): assert "Projects" in prompt # the enum now advertises Projects to the model return { "section": "Projects", "confidence": 0.83, "reason": "project block", "title": "JobTrack", "company": None, "location": None, "start": None, "end": None, "bullets": ["Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."], "summary": [], "skills": [], } monkeypatch.setattr(module, "_ollama_generate_json", fake_generate_json) client = TestClient(module.app) response = client.post("/cv/classify-block", json={"block": "JobTrack - Full-stack job-application tracker (React, ASP.NET Core, SQLite, Docker)."}) assert response.status_code == 200 payload = response.json() assert payload["section"] == "Projects" assert payload["title"] == "JobTrack" def test_classify_block_defaults_missing_section_to_other(monkeypatch): module = load_app_module(monkeypatch) monkeypatch.setattr(module, "_ollama_generate_json", lambda prompt: {"bullets": []}) client = TestClient(module.app) response = client.post("/cv/classify-block", json={"block": "Miscellaneous profile text"}) assert response.status_code == 200 payload = response.json() assert payload["section"] == "Other" assert payload["bullets"] == [] assert payload["summary"] == [] assert payload["skills"] == [] # --- AI provider router ------------------------------------------------------- class _FakeResponse: def __init__(self, payload): self._data = json.dumps(payload).encode("utf-8") def read(self): return self._data def __enter__(self): return self def __exit__(self, *exc): return False def _install_fake_urlopen(monkeypatch, module, response_payload, captured): def fake_urlopen(req, timeout=None): captured["url"] = req.full_url captured["headers"] = {k.lower(): v for k, v in req.header_items()} captured["body"] = json.loads(req.data.decode("utf-8")) return _FakeResponse(response_payload) monkeypatch.setattr(module.urllib_request, "urlopen", fake_urlopen) def test_provider_defaults_to_ollama_and_is_unchanged(monkeypatch): monkeypatch.delenv("AI_PROVIDER", raising=False) monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama-host:11434") module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b") assert module.AI_PROVIDER == "ollama" captured = {} _install_fake_urlopen(monkeypatch, module, {"response": '{"score": 7}'}, captured) assert module._ollama_generate_json("hi") == {"score": 7} assert captured["url"] == "http://ollama-host:11434/api/generate" assert captured["body"]["model"] == "qwen2.5:7b" assert captured["body"]["format"] == "json" assert captured["body"]["options"]["temperature"] == 0.1 def test_provider_gemini_dispatch(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.setenv("GEMINI_API_KEY", "test-key") monkeypatch.setenv("GEMINI_MODEL", "gemini-2.0-flash") module = load_app_module(monkeypatch, external_ai_enabled=True) module._external_ai_allowed.set(True) captured = {} payload = {"candidates": [{"content": {"parts": [{"text": '{"score": 9}'}]}}]} _install_fake_urlopen(monkeypatch, module, payload, captured) assert module._ollama_generate_json("hi") == {"score": 9} assert "generativelanguage" in captured["url"] assert "gemini-2.0-flash:generateContent" in captured["url"] assert "key=" not in captured["url"] # key must not be in the URL assert captured["headers"].get("x-goog-api-key") == "test-key" assert captured["body"]["generationConfig"]["responseMimeType"] == "application/json" def test_provider_groq_dispatch(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "groq") monkeypatch.setenv("GROQ_API_KEY", "test-key") module = load_app_module(monkeypatch, external_ai_enabled=True) module._external_ai_allowed.set(True) captured = {} payload = {"choices": [{"message": {"content": "rewritten CV text"}}]} _install_fake_urlopen(monkeypatch, module, payload, captured) assert module._ollama_generate_text("rewrite this") == "rewritten CV text" assert captured["url"].endswith("/chat/completions") assert captured["headers"].get("authorization") == "Bearer test-key" assert captured["body"]["messages"][0]["content"] == "rewrite this" def test_provider_missing_cloud_key_raises_503(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.delenv("GEMINI_API_KEY", raising=False) module = load_app_module(monkeypatch, external_ai_enabled=True) module._external_ai_allowed.set(True) from fastapi import HTTPException try: module._ollama_generate_json("hi") except HTTPException as ex: assert ex.status_code == 503 assert "GEMINI_API_KEY" in ex.detail else: raise AssertionError("expected HTTPException for missing GEMINI_API_KEY") def test_health_reports_active_provider(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.setenv("GEMINI_API_KEY", "test-key") module = load_app_module(monkeypatch) client = TestClient(module.app) payload = client.get("/health").json() assert payload["ai_provider"] == "gemini" assert payload["ai_provider_configured"] is True def test_external_provider_requires_admin_gate_and_backend_consent_header(monkeypatch): monkeypatch.setenv("AI_PROVIDER", "gemini") monkeypatch.setenv("GEMINI_API_KEY", "test-key") module = load_app_module(monkeypatch, ollama_model="qwen2.5:7b", external_ai_enabled=True) captured = {} _install_fake_urlopen(monkeypatch, module, {"response": "rewritten locally"}, captured) client = TestClient(module.app) local_response = client.post("/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"}) assert local_response.status_code == 200 assert captured["url"].startswith("http://127.0.0.1:11434/") assert local_response.headers["X-Ai-Provider"] == "ollama" external_payload = {"candidates": [{"content": {"parts": [{"text": "rewritten externally"}]}}]} _install_fake_urlopen(monkeypatch, module, external_payload, captured) external_response = client.post( "/cv/rewrite", json={"instruction": "Rewrite", "text": "Synthetic CV"}, headers={"X-Ai-External-Allowed": "true"}, ) assert external_response.status_code == 200 assert "generativelanguage" in captured["url"] assert external_response.headers["X-Ai-Provider"] == "gemini" def test_service_token_rejects_calls_without_the_header(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") client = TestClient(module.app) response = client.post("/summarize", json={"text": "Platform engineering role."}) assert response.status_code == 401 assert "token" in response.json()["detail"].lower() def test_service_token_rejects_a_wrong_header(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") client = TestClient(module.app) response = client.post( "/summarize", json={"text": "Platform engineering role."}, headers={"X-Ai-Service-Token": "wrong"}, ) assert response.status_code == 401 def test_service_token_allows_the_correct_header(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") client = TestClient(module.app) response = client.post( "/summarize", json={"text": "Platform engineering role."}, headers={"X-Ai-Service-Token": "s3cret"}, ) # 503 = passed the token gate and reached the handler, which is model-disabled here. assert response.status_code == 503 def test_health_stays_open_so_probes_and_healthchecks_work(monkeypatch): module = load_app_module(monkeypatch, service_token="s3cret") client = TestClient(module.app) assert client.get("/health").status_code == 200 def test_endpoints_stay_open_when_no_token_is_configured(monkeypatch): module = load_app_module(monkeypatch) client = TestClient(module.app) # Keyless local dev: reaches the handler (503 model-disabled), not a 401. response = client.post("/summarize", json={"text": "Platform engineering role."}) assert response.status_code == 503