"""Unit tests for the Ollama HTTP client wrapper. We swap ``httpx.Client`` inside ``ocr_sprint.llm.client`` for a builder that returns a real ``httpx.Client`` wrapping a ``MockTransport``. Capturing the original constructor *before* patching avoids infinite recursion in the patched callable. """ from __future__ import annotations from typing import Any import httpx import pytest from pydantic import BaseModel import ocr_sprint.llm.client as llm_client_module from ocr_sprint.llm.client import LLMUnavailableError, OllamaClient class _Schema(BaseModel): foo: str bar: int def _ollama_envelope(content: str) -> dict[str, object]: """Mimic the shape Ollama's /api/chat returns.""" return {"message": {"role": "assistant", "content": content}, "done": True} def _patch_transport( monkeypatch: pytest.MonkeyPatch, handler: Any, ) -> None: transport = httpx.MockTransport(handler) real_client = httpx.Client # capture before patching def _factory(*_args: object, **kwargs: object) -> httpx.Client: # Strip any caller-provided transport kwarg; we always inject ours. kwargs.pop("transport", None) return real_client(transport=transport, **kwargs) monkeypatch.setattr(llm_client_module.httpx, "Client", _factory) def test_chat_json_returns_validated_model(monkeypatch: pytest.MonkeyPatch) -> None: captured: dict[str, object] = {} def _handler(request: httpx.Request) -> httpx.Response: captured["url"] = str(request.url) captured["body"] = request.read() return httpx.Response(200, json=_ollama_envelope('{"foo": "x", "bar": 7}')) _patch_transport(monkeypatch, _handler) client = OllamaClient(base_url="http://ollama:11434", model="m", timeout_s=5) out = client.chat_json("system msg", "user msg", _Schema) assert out == _Schema(foo="x", bar=7) assert captured["url"] == "http://ollama:11434/api/chat" body = captured["body"] assert isinstance(body, bytes) assert b'"format":"json"' in body assert b'"system msg"' in body def test_chat_json_raises_on_http_error(monkeypatch: pytest.MonkeyPatch) -> None: def _handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(500, text="boom") _patch_transport(monkeypatch, _handler) client = OllamaClient(base_url="http://x", model="m", timeout_s=5) with pytest.raises(LLMUnavailableError, match="Ollama request failed"): client.chat_json("s", "u", _Schema) def test_chat_json_raises_on_invalid_json(monkeypatch: pytest.MonkeyPatch) -> None: def _handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, json=_ollama_envelope("this is not json")) _patch_transport(monkeypatch, _handler) client = OllamaClient(base_url="http://x", model="m", timeout_s=5) with pytest.raises(LLMUnavailableError, match="schema"): client.chat_json("s", "u", _Schema) def test_chat_json_raises_on_missing_envelope(monkeypatch: pytest.MonkeyPatch) -> None: def _handler(_request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"oops": True}) _patch_transport(monkeypatch, _handler) client = OllamaClient(base_url="http://x", model="m", timeout_s=5) with pytest.raises(LLMUnavailableError, match=r"message\.content"): client.chat_json("s", "u", _Schema) def test_chat_json_raises_on_connection_error(monkeypatch: pytest.MonkeyPatch) -> None: def _handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("nobody home", request=request) _patch_transport(monkeypatch, _handler) client = OllamaClient(base_url="http://x", model="m", timeout_s=1) with pytest.raises(LLMUnavailableError): client.chat_json("s", "u", _Schema)