Files
markitect-main/tests/unit/llm/test_http.py
tegwick fecc2fd4fa feat(llm): add LLM integration module with OpenRouter and Claude Code adapters
Implements markitect/llm/ package with concrete LLMAdapter implementations:
- OpenRouterAdapter: HTTP via urllib with retry/backoff on 429/5xx
- ClaudeCodeAdapter: subprocess-based Claude CLI with stdin piping
- Factory pattern: create_adapter("openrouter") or create_adapter("claude-code")
- API key resolution chain: constructor > env var > project-root key file
- 42 unit tests, 2 integration tests (gated on API key / CLI availability)

Also adds the infospace-with-history example with Wealth of Nations VSM
analysis pipeline, templates, schemas, source chapters, and processed
output for chapters 1-2. process_chapters.py now supports --provider
and --model flags for automatic LLM-driven processing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 01:17:58 +01:00

76 lines
2.9 KiB
Python

"""Tests for markitect.llm._http."""
import json
import urllib.error
from unittest import mock
import pytest
from markitect.llm._http import post_json
from markitect.llm.exceptions import LLMAPIError, LLMRateLimitError, LLMTimeoutError
class TestPostJson:
def _mock_urlopen(self, response_body: dict, status: int = 200):
"""Return a context-manager mock for urllib.request.urlopen."""
body_bytes = json.dumps(response_body).encode()
resp = mock.MagicMock()
resp.read.return_value = body_bytes
resp.__enter__ = mock.MagicMock(return_value=resp)
resp.__exit__ = mock.MagicMock(return_value=False)
return resp
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_success(self, mock_urlopen):
expected = {"choices": [{"message": {"content": "hi"}}]}
mock_urlopen.return_value = self._mock_urlopen(expected)
result = post_json("https://api.test/v1", {"prompt": "hello"})
assert result == expected
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_429_raises_rate_limit(self, mock_urlopen):
exc = urllib.error.HTTPError(
"https://api.test/v1", 429, "Too Many Requests", {}, None
)
exc.read = mock.MagicMock(return_value=b"rate limited")
mock_urlopen.side_effect = exc
with pytest.raises(LLMRateLimitError) as exc_info:
post_json("https://api.test/v1", {})
assert exc_info.value.status_code == 429
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_500_raises_api_error(self, mock_urlopen):
exc = urllib.error.HTTPError(
"https://api.test/v1", 500, "Internal Server Error", {}, None
)
exc.read = mock.MagicMock(return_value=b"server error")
mock_urlopen.side_effect = exc
with pytest.raises(LLMAPIError) as exc_info:
post_json("https://api.test/v1", {})
assert exc_info.value.status_code == 500
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_timeout_raises_timeout_error(self, mock_urlopen):
exc = urllib.error.URLError("timed out")
mock_urlopen.side_effect = exc
with pytest.raises(LLMTimeoutError):
post_json("https://api.test/v1", {})
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_generic_url_error_raises_api_error(self, mock_urlopen):
exc = urllib.error.URLError("connection refused")
mock_urlopen.side_effect = exc
with pytest.raises(LLMAPIError):
post_json("https://api.test/v1", {})
@mock.patch("markitect.llm._http.urllib.request.urlopen")
def test_python_timeout_error(self, mock_urlopen):
mock_urlopen.side_effect = TimeoutError("timed out")
with pytest.raises(LLMTimeoutError):
post_json("https://api.test/v1", {})