diff --git a/tests/test_api_openai.py b/tests/test_api_openai.py index 71bc3968..31915bc3 100644 --- a/tests/test_api_openai.py +++ b/tests/test_api_openai.py @@ -442,3 +442,230 @@ class TestAiExtractionEndpoint: assert data["status"] == "success" assert data["tags"] == ["contract"] assert data["parse_error"] is None + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_extraction_json_found_but_invalid_reports_parse_error(self, mock_settings, mock_get_provider, client): + """Test JSONDecodeError branch: response contains '{...}' but is not valid JSON.""" + mock_settings.ai_provider = "openai" + mock_settings.ai_model = "gpt-4o-mini" + mock_settings.openai_model = "gpt-4o-mini" + + # Looks like JSON (has { and }) but is NOT parseable + mock_provider = MagicMock() + mock_provider.chat_completion.return_value = "{this is not: valid json!!}" + mock_get_provider.return_value = mock_provider + + response = client.post("/api/ai/test-extraction", json={"text": "Some document text"}) + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + assert data["parsed_json"] is None + assert data["tags"] == [] + assert data["parse_error"] is not None + assert "raw_response" in data + + @patch("app.api.openai.settings") + def test_extraction_text_too_long_returns_422(self, mock_settings, client): + """Test that text exceeding max length returns 422 validation error.""" + from app.api.openai import _MAX_EXTRACTION_TEXT_LEN + + oversized_text = "x" * (_MAX_EXTRACTION_TEXT_LEN + 1) + response = client.post("/api/ai/test-extraction", json={"text": oversized_text}) + assert response.status_code == 422 + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_extraction_response_with_no_tags_key(self, mock_settings, mock_get_provider, client): + """Test extraction where parsed JSON has no 'tags' key returns empty tags list.""" + import json as json_module + + mock_settings.ai_provider = "openai" + mock_settings.ai_model = "gpt-4o-mini" + mock_settings.openai_model = "gpt-4o-mini" + + # Valid JSON but no 'tags' key + payload = {"title": "Report", "document_type": "Report"} + mock_provider = MagicMock() + mock_provider.chat_completion.return_value = json_module.dumps(payload) + mock_get_provider.return_value = mock_provider + + response = client.post("/api/ai/test-extraction", json={"text": "Report content"}) + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + assert data["tags"] == [] + assert data["parsed_json"]["title"] == "Report" + assert data["parse_error"] is None + + +@pytest.mark.unit +class TestAiProviderTestEndpoint: + """Tests for GET /api/ai/test endpoint.""" + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_ai_test_success(self, mock_settings, mock_get_provider, client): + """Test successful AI provider connection.""" + mock_settings.ai_provider = "openai" + mock_settings.ai_model = "gpt-4o-mini" + mock_settings.openai_model = "gpt-4o-mini" + + mock_provider = MagicMock() + mock_provider.chat_completion.return_value = "ok" + mock_get_provider.return_value = mock_provider + + response = client.get("/api/ai/test") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + assert "reachable" in data["message"].lower() + assert data["provider"] == "openai" + assert data["model"] == "gpt-4o-mini" + assert "response_preview" in data + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_ai_test_value_error(self, mock_settings, mock_get_provider, client): + """Test GET /api/ai/test returns error on provider configuration ValueError.""" + mock_settings.ai_provider = "anthropic" + mock_settings.ai_model = None + mock_settings.openai_model = "gpt-4o-mini" + + mock_get_provider.side_effect = ValueError("ANTHROPIC_API_KEY must be set") + + response = client.get("/api/ai/test") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "error" + assert "ANTHROPIC_API_KEY" in data["message"] + assert data["provider"] == "anthropic" + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_ai_test_connection_exception(self, mock_settings, mock_get_provider, client): + """Test GET /api/ai/test returns error on provider runtime exception.""" + mock_settings.ai_provider = "openai" + mock_settings.ai_model = "gpt-4o-mini" + mock_settings.openai_model = "gpt-4o-mini" + + mock_provider = MagicMock() + mock_provider.chat_completion.side_effect = Exception("Connection refused") + mock_get_provider.return_value = mock_provider + + response = client.get("/api/ai/test") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "error" + assert "Connection refused" in data["message"] + assert data["provider"] == "openai" + + @patch("app.utils.ai_provider.get_ai_provider") + @patch("app.api.openai.settings") + def test_ai_test_uses_openai_model_fallback(self, mock_settings, mock_get_provider, client): + """Test that ai_model=None falls back to openai_model.""" + mock_settings.ai_provider = "openai" + mock_settings.ai_model = None + mock_settings.openai_model = "gpt-3.5-turbo" + + mock_provider = MagicMock() + mock_provider.chat_completion.return_value = "ok" + mock_get_provider.return_value = mock_provider + + response = client.get("/api/ai/test") + assert response.status_code == 200 + data = response.json() + + assert data["status"] == "success" + assert data["model"] == "gpt-3.5-turbo" + + +@pytest.mark.unit +class TestExceptionChainDetail: + """Unit tests for the _get_exception_chain_detail helper.""" + + def test_single_exception_no_chain(self): + """Test with a simple exception that has no cause.""" + from app.api.openai import _get_exception_chain_detail + + exc = ValueError("root cause") + result = _get_exception_chain_detail(exc) + assert result == "root cause" + + def test_exception_with_cause(self): + """Test that chained exceptions are surfaced in the message.""" + from app.api.openai import _get_exception_chain_detail + + inner = OSError("DNS resolution failed") + outer = ConnectionError("Connection failed") + outer.__cause__ = inner + + result = _get_exception_chain_detail(outer) + assert "Connection failed" in result + assert "DNS resolution failed" in result + assert "caused by" in result + + def test_empty_cause_string_skipped(self): + """Test that a cause with empty string representation is not appended.""" + from app.api.openai import _get_exception_chain_detail + + inner = Exception("") # str() returns "" + outer = RuntimeError("outer error") + outer.__cause__ = inner + + result = _get_exception_chain_detail(outer) + # The empty-string cause should be skipped (branch 47->49) + assert result == "outer error" + + def test_duplicate_cause_string_skipped(self): + """Test that a cause whose str() is already in parts is not duplicated.""" + from app.api.openai import _get_exception_chain_detail + + outer = RuntimeError("same message") + inner = RuntimeError("same message") # same text as outer + outer.__cause__ = inner + + result = _get_exception_chain_detail(outer) + # "same message" should appear only once (branch: cause_str already in parts) + assert result.count("same message") == 1 + + +@pytest.mark.unit +class TestExtractJsonFromText: + """Unit tests for the _extract_json_from_text helper.""" + + def test_json_in_code_fence(self): + """Test extraction from markdown code fence.""" + from app.api.openai import _extract_json_from_text + + text = '```json\n{"key": "value"}\n```' + result = _extract_json_from_text(text) + assert result == '{"key": "value"}' + + def test_json_in_plain_code_fence(self): + """Test extraction from plain (non-json) code fence.""" + from app.api.openai import _extract_json_from_text + + text = '```\n{"key": "value"}\n```' + result = _extract_json_from_text(text) + assert result == '{"key": "value"}' + + def test_bare_json_object(self): + """Test extraction of a bare JSON object without code fence.""" + from app.api.openai import _extract_json_from_text + + text = 'Here is the result: {"title": "Invoice"} done.' + result = _extract_json_from_text(text) + assert result == '{"title": "Invoice"}' + + def test_no_json_returns_none(self): + """Test that text with no JSON object returns None.""" + from app.api.openai import _extract_json_from_text + + result = _extract_json_from_text("No JSON here at all.") + assert result is None