test(api): add comprehensive tests for AI extraction endpoint reaching 100% coverage
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user