Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions tests/infrastructure/test_llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
from unittest.mock import AsyncMock, MagicMock, patch

import httpx
import pytest

from src.config import settings
from src.infrastructure import llm


def mock_async_client(json_data=None, raise_for_status_side_effect=None):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test behaviour looks correct! One code style suggestion:

The mock_async_client helper currently uses patcher.start() and every test needs its own try/finally: patcher.stop() to clean up. If you make the helper a @contextmanager, cleanup becomes automatic and the per-test boilerplate goes away. The response/client setup at the top is unchanged — only the patcher.start() lines and the return change:

  • Add from contextlib import contextmanager at the top
  • Add @contextmanager above the helper definition
  • Setup stays the same up to client.post = AsyncMock(return_value=response). After that, replace:
patcher = patch("src.infrastructure.llm.httpx.AsyncClient")
mocked_async_client_cls = patcher.start()

with

with patch("src.infrastructure.llm.httpx.AsyncClient") as mocked_async_client_cls:

and put the rest of the lines indented inside the with block

  • The final return would just become yield client, response

Then each test can drop the patcher, client, _ = ... and try/finally lines and just have something like:

with mock_async_client(json_data={"embedding": fake_embedding}) as (client, _):
    <test code...>

If a test doesn't use client/response (like the one on line 64 currently), you can drop the as (client, _) entirely and just write with mock_async_client(...):

These changes help trim some boilerplate code in the tests and help prevent a hypothetical future test from forgetting the stop() for cleanup.

response = MagicMock()
response.json.return_value = json_data or {}
response.raise_for_status.side_effect = raise_for_status_side_effect

client = AsyncMock()
client.post = AsyncMock(return_value=response)

patcher = patch("src.infrastructure.llm.httpx.AsyncClient")
mocked_async_client_cls = patcher.start()

mocked_context_manager = AsyncMock()
mocked_context_manager.__aenter__.return_value = client
mocked_context_manager.__aexit__.return_value = None

mocked_async_client_cls.return_value = mocked_context_manager

return patcher, client, response


def test_embed_document_prefix_value_is_pinned():
assert llm.EMBED_DOCUMENT_PREFIX == "search_document: "


async def test_embed_uses_query_prefix_and_embedding_model():
fake_embedding = [0.0] * settings.embedding_dim
patcher, client, _ = mock_async_client(json_data={"embedding": fake_embedding})

try:
result = await llm.embed("hello", is_query=True)

assert result == fake_embedding
args, kwargs = client.post.call_args

assert args[0].endswith("/api/embeddings")
assert kwargs["json"]["model"] == settings.ollama_embedding_model
assert kwargs["json"]["prompt"] == f"{llm.EMBED_QUERY_PREFIX}hello"
finally:
patcher.stop()


async def test_embed_uses_document_prefix():
fake_embedding = [0.0] * settings.embedding_dim
patcher, client, _ = mock_async_client(json_data={"embedding": fake_embedding})

try:
await llm.embed("hello", is_query=False)

_, kwargs = client.post.call_args
assert kwargs["json"]["prompt"] == f"{llm.EMBED_DOCUMENT_PREFIX}hello"
finally:
patcher.stop()


async def test_embed_raises_value_error_on_dimension_mismatch():
wrong_embedding = [0.0] * (settings.embedding_dim - 1)
patcher, _, _ = mock_async_client(json_data={"embedding": wrong_embedding})

try:
with pytest.raises(ValueError, match="mismatch"):
await llm.embed("hello", is_query=True)
finally:
patcher.stop()


async def test_chat_uses_chat_model_and_returns_message_content():
patcher, client, _ = mock_async_client(json_data={"message": {"content": "hi back"}})

messages = [{"role": "user", "content": "hello"}]

try:
result = await llm.chat(messages)

assert result == "hi back"
args, kwargs = client.post.call_args

assert args[0].endswith("/api/chat")
assert kwargs["json"]["model"] == settings.ollama_chat_model
assert kwargs["json"]["messages"] == messages
assert kwargs["json"]["stream"] is False
finally:
patcher.stop()


async def test_http_errors_propagate_for_embed_and_chat():
request = httpx.Request("POST", "http://test/api")
response = httpx.Response(500, request=request)
error = httpx.HTTPStatusError("boom", request=request, response=response)

patcher, _, _ = mock_async_client(
json_data={},
raise_for_status_side_effect=error,
)

try:
with pytest.raises(httpx.HTTPStatusError):
await llm.embed("hello", is_query=True)

with pytest.raises(httpx.HTTPStatusError):
await llm.chat([{"role": "user", "content": "hello"}])
finally:
patcher.stop()
Loading