summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test_llm.py101
-rw-r--r--tests/test_logging.py88
-rw-r--r--tests/test_settings.py25
-rw-r--r--tests/test_tools.py49
4 files changed, 205 insertions, 58 deletions
diff --git a/tests/test_llm.py b/tests/test_llm.py
index edd9d61..0e1b146 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -9,13 +9,11 @@ from typing import Any
import pytest
from ollama import Message
from ollama._types import Image as OllamaImage
-from ollama._types import ResponseError
from ollama_web.llm import (
_embed_images_in_content,
_is_image_message,
_token_counts,
- chat_with_tools,
stream_chat_with_tools,
)
from ollama_web.tools.registry import ToolRegistry
@@ -44,46 +42,44 @@ def test_embed_images_in_content_adds_base64_markdown() -> None:
assert not getattr(embedded, "images", None)
-def test_chat_with_tools_skips_tools_for_non_tool_model() -> None:
- """Tools are not passed when the model does not advertise tool support."""
- messages = [Message(role="user", content="hi")]
- # Use an empty registry so any tool call would fail if requested.
+def test_stream_chat_with_tools_disables_unsupported_capabilities(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Unsupported tools and thinking options are not sent to Ollama."""
+ from ollama_web import llm
+
registry = ToolRegistry()
- # Without tool capability, the iterator should finish immediately with no
- # actual network request. We only verify it accepts the call and does not
- # raise due to tool-related setup.
- gen = chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities=set(),
- registry=registry,
- )
- events = list(gen)
- # We expect either an error (model not found) or a done event.
- assert any(e.get("type") in {"error", "done"} for e in events)
+ def lookup(query: str) -> str:
+ return query
+ registry.register(lookup)
-def test_chat_with_tools_forces_think_none_for_non_thinking_model() -> None:
- messages = [Message(role="user", content="hi")]
- gen = chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities=set(),
- )
- events = list(gen)
- assert any(e.get("type") in {"error", "done"} for e in events)
+ class FakeClient:
+ def __init__(self) -> None:
+ self.calls: list[dict[str, Any]] = []
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
+ self.calls.append(kwargs)
+ return [SimpleNamespace(message={"content": "done"})]
+
+ client = FakeClient()
+ monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
-def test_stream_chat_with_tools_accepts_capability_set() -> None:
- messages = [Message(role="user", content="hi")]
- gen = stream_chat_with_tools(
- messages,
- model="nonexistent-model",
- capabilities={"tools", "thinking"},
+ events = list(
+ stream_chat_with_tools(
+ [Message(role="user", content="hi")],
+ model="test-model",
+ think=True,
+ capabilities=set(),
+ registry=registry,
+ )
)
- with pytest.raises(ResponseError):
- list(gen)
+
+ assert client.calls[0]["tools"] == []
+ assert client.calls[0]["think"] is None
+ assert client.calls[0]["stream"] is True
+ assert events[-1] == {"type": "done"}
def test_token_counts_extracts_from_dict() -> None:
@@ -109,7 +105,7 @@ def test_token_counts_returns_none_when_missing() -> None:
assert _token_counts(None) is None
-def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
+def test_stream_chat_with_tools_disables_tools_for_final_answer_after_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from ollama_web import llm
@@ -128,26 +124,33 @@ def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
- def chat(self, **kwargs: Any) -> dict[str, Any]:
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
self.calls.append(kwargs)
if len(self.calls) == 1:
- return {
- "message": {
- "role": "assistant",
- "content": "",
- "tool_calls": [
- {"function": {"name": "lookup", "arguments": {"query": "test"}}}
- ],
- }
- }
- return {"message": {"role": "assistant", "content": "final answer"}}
+ return [
+ SimpleNamespace(
+ message={
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "lookup",
+ "arguments": {"query": "test"},
+ }
+ }
+ ],
+ }
+ )
+ ]
+ return [SimpleNamespace(message={"content": "final answer"})]
client = FakeClient()
monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
monkeypatch.setattr(llm, "MAX_TOOL_ROUNDS", 1)
events = list(
- llm.chat_with_tools(
+ llm.stream_chat_with_tools(
[Message(role="user", content="research this")],
model="test-model",
registry=registry,
@@ -160,6 +163,8 @@ def test_chat_with_tools_disables_tools_for_final_answer_after_limit(
assert len(client.calls) == 2
assert client.calls[0]["tools"]
assert client.calls[1]["tools"] == []
+ assert client.calls[0]["stream"] is True
+ assert client.calls[1]["stream"] is True
assert any(event.get("type") == "status" for event in events)
assert any(event == {"type": "delta", "content": "final answer"} for event in events)
assert events[-1] == {"type": "done"}
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 0000000..ef6fbd2
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,88 @@
+"""Tests for application file logging."""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from starlette.testclient import TestClient
+
+from ollama_web.app import create_app
+from ollama_web.config import settings
+from ollama_web.tools.registry import default_registry
+
+
+def test_server_log_is_scoped_to_data_dir_and_app_lifespan(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+ app_logger = logging.getLogger("ollama_web")
+ original_handlers = tuple(app_logger.handlers)
+ original_level = app_logger.level
+
+ app = create_app()
+ log_path = data_dir / "server.log"
+ assert not log_path.exists()
+
+ with TestClient(app):
+ assert len(app_logger.handlers) == len(original_handlers) + 1
+ logging.getLogger("ollama_web.test").info("lifespan log marker")
+
+ assert tuple(app_logger.handlers) == original_handlers
+ assert app_logger.level == original_level
+ assert log_path.read_text(encoding="utf-8").count("lifespan log marker") == 1
+
+ # Closing the lifespan must release the file on Windows as well.
+ log_path.unlink()
+ assert not log_path.exists()
+
+
+def test_repeated_app_lifespans_do_not_duplicate_log_handlers(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+
+ with TestClient(create_app()):
+ logging.getLogger("ollama_web.test").info("first app marker")
+ with TestClient(create_app()):
+ logging.getLogger("ollama_web.test").info("second app marker")
+
+ content = (data_dir / "server.log").read_text(encoding="utf-8")
+ assert content.count("first app marker") == 1
+ assert content.count("second app marker") == 1
+
+
+def test_web_search_provider_error_is_written_to_server_log(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data_dir = tmp_path / "data"
+ monkeypatch.setattr(settings, "data_dir", str(data_dir))
+
+ class FailingDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ raise RuntimeError("403 Forbidden")
+
+ with patch("ollama_web.tools.search.DDGS", FailingDDGS), TestClient(create_app()):
+ result = json.loads(
+ default_registry().execute("web_search", {"query": "private search terms"})
+ )
+
+ assert result == {"error": "RuntimeError: 403 Forbidden"}
+ content = (data_dir / "server.log").read_text(encoding="utf-8")
+ assert "DuckDuckGo web search failed" in content
+ assert "RuntimeError: 403 Forbidden" in content
+ assert "private search terms" not in content
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 0f334f4..d19f64d 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
from pathlib import Path
+from types import SimpleNamespace
from typing import Any
import pytest
@@ -151,24 +152,28 @@ def test_options_are_forwarded_to_tool_and_final_rounds(monkeypatch: pytest.Monk
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
- def chat(self, **kwargs: Any) -> dict[str, Any]:
+ def chat(self, **kwargs: Any) -> list[SimpleNamespace]:
self.calls.append(kwargs)
if len(self.calls) == 1:
- return {
- "message": {
- "role": "assistant",
- "content": "",
- "tool_calls": [{"function": {"name": "missing_tool", "arguments": {}}}],
- }
- }
- return {"message": {"role": "assistant", "content": "done"}}
+ return [
+ SimpleNamespace(
+ message={
+ "role": "assistant",
+ "content": "",
+ "tool_calls": [
+ {"function": {"name": "missing_tool", "arguments": {}}}
+ ],
+ }
+ )
+ ]
+ return [SimpleNamespace(message={"content": "done"})]
client = FakeClient()
monkeypatch.setattr(llm, "get_client", lambda _host=None: client)
monkeypatch.setattr(llm, "MAX_TOOL_ROUNDS", 1)
options = {"temperature": 0.8, "num_ctx": 8192, "top_p": 0.9}
events = list(
- llm.chat_with_tools(
+ llm.stream_chat_with_tools(
[Message(role="user", content="hello")],
"test-model",
capabilities={"tools"},
diff --git a/tests/test_tools.py b/tests/test_tools.py
index a94ea52..387cbe2 100644
--- a/tests/test_tools.py
+++ b/tests/test_tools.py
@@ -114,6 +114,55 @@ def test_web_search_mocked():
assert result[0]["title"] == "T"
+def test_web_search_returns_empty_list_for_no_provider_results() -> None:
+ from ollama_web.tools import search as search_mod
+
+ class EmptyDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ return []
+
+ with patch.object(search_mod, "DDGS", EmptyDDGS):
+ assert search_mod.web_search("nothing found") == []
+
+
+def test_web_search_logs_and_propagates_provider_errors(
+ caplog: pytest.LogCaptureFixture,
+) -> None:
+ from ollama_web.tools import search as search_mod
+
+ class FailingDDGS:
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args):
+ return None
+
+ def text(self, query, max_results=5):
+ raise RuntimeError("403 Forbidden")
+
+ with (
+ patch.object(search_mod, "DDGS", FailingDDGS),
+ caplog.at_level("ERROR", logger="ollama_web.tools.search"),
+ ):
+ with pytest.raises(RuntimeError, match="403 Forbidden"):
+ search_mod.web_search("private search terms")
+
+ assert "DuckDuckGo web search failed" in caplog.text
+ assert "private search terms" not in caplog.text
+
+ registry = ToolRegistry()
+ registry.register(search_mod.web_search)
+ with patch.object(search_mod, "DDGS", FailingDDGS):
+ result = json.loads(registry.execute("web_search", {"query": "test"}))
+ assert result == {"error": "RuntimeError: 403 Forbidden"}
+
+
# ---------------------------------------------------------------------------
# scrape_url tests (existing + pagination)
# ---------------------------------------------------------------------------