Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 10 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,22 @@ packages = ["librarian"]

[dependency-groups]
dev = [
"httpx>=0.28.0",
"pre-commit>=4.5.1",
"pyright",
"pytest",
"pytest-cov",
"ruff",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--cov=librarian --cov-report=term-missing -q"

[tool.coverage.run]
source = ["librarian"]
omit = ["tests/*"]

[tool.pyright]
include = ["librarian"]
pythonVersion = "3.12"
Expand Down
Empty file added api/tests/__init__.py
Empty file.
27 changes: 27 additions & 0 deletions api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""
Pytest configuration and shared fixtures.

This module runs before any test file is imported, so it's the right place
to set required environment variables and mock heavy module-level imports.
"""

import os
import sys
from unittest.mock import MagicMock

# ---------------------------------------------------------------------------
# Required environment variables — must be set before librarian is imported.
# ---------------------------------------------------------------------------
os.environ.setdefault("VLLM_BASE_URL", "http://localhost:8000/v1")
os.environ.setdefault("VLLM_API_KEY", "test-api-key")
os.environ.setdefault("VLLM_MODEL_NAME", "test-model")
os.environ.setdefault("SERPER_API_TOKEN", "test-serper-token")

# ---------------------------------------------------------------------------
# Mock `transformers` before _tools.py is imported.
# _tools.py calls AutoTokenizer.from_pretrained() at the module level, which
# would try to download a model from HuggingFace. Replacing the whole module
# in sys.modules prevents that download in all test runs.
# ---------------------------------------------------------------------------
if "transformers" not in sys.modules:
sys.modules["transformers"] = MagicMock()
201 changes: 201 additions & 0 deletions api/tests/test_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
"""Smoke tests for librarian/server.py — SSE helpers and FastAPI endpoints."""

import json
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from fastapi.testclient import TestClient

from librarian.server import (
app,
format_sse,
format_sse_comment,
get_cors_origins,
sessions,
)

client = TestClient(app)


@pytest.fixture(autouse=True)
def clear_sessions():
"""Isolate session state between tests."""
sessions.clear()
yield
sessions.clear()


# ---------------------------------------------------------------------------
# SSE formatting helpers
# ---------------------------------------------------------------------------


class TestFormatSse:
def test_event_line(self):
result = format_sse("node", {"x": 1})
assert result.startswith("event: node\n")

def test_data_line_is_json(self):
data = {"answer": "42", "references": ["https://a.com"]}
result = format_sse("answer", data)
data_line = result.split("\n")[1]
assert data_line.startswith("data: ")
assert json.loads(data_line[len("data: ") :]) == data

def test_ends_with_double_newline(self):
result = format_sse("ping", {})
assert result.endswith("\n\n")

def test_exact_format(self):
result = format_sse("node", {"k": "v"})
assert result == 'event: node\ndata: {"k": "v"}\n\n'


class TestFormatSseComment:
def test_keepalive_format(self):
assert format_sse_comment() == ": keepalive\n\n"


# ---------------------------------------------------------------------------
# CORS origins helper
# ---------------------------------------------------------------------------


class TestGetCorsOrigins:
def test_defaults_when_env_var_absent(self, monkeypatch):
monkeypatch.delenv("CORS_ORIGINS", raising=False)
origins = get_cors_origins()
assert "http://localhost:8080" in origins
assert "http://localhost:8001" in origins

def test_parses_comma_separated_env_var(self, monkeypatch):
monkeypatch.setenv(
"CORS_ORIGINS", "http://app.example.com, http://other.example.com"
)
origins = get_cors_origins()
assert origins == ["http://app.example.com", "http://other.example.com"]

def test_single_origin(self, monkeypatch):
monkeypatch.setenv("CORS_ORIGINS", "http://app.example.com")
assert get_cors_origins() == ["http://app.example.com"]


# ---------------------------------------------------------------------------
# DELETE /api/session/{session_id}
# ---------------------------------------------------------------------------


class TestClearSession:
def test_clears_existing_session(self):
sessions["sess-1"] = []
response = client.delete("/api/session/sess-1")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
assert "sess-1" not in sessions

def test_nonexistent_session_still_returns_ok(self):
response = client.delete("/api/session/nonexistent")
assert response.status_code == 200
assert response.json() == {"status": "ok"}


# ---------------------------------------------------------------------------
# POST /api/query
# ---------------------------------------------------------------------------


def _make_fake_run_agent(*events: dict):
"""Return an async generator function that yields the given SSE events."""

async def fake_run_agent(*args, **kwargs):
for event_type, data in events:
yield format_sse(event_type, data)

return fake_run_agent


class TestQueryEndpoint:
def test_returns_200_with_event_stream(self):
fake = _make_fake_run_agent(("answer", {"answer": "ok", "references": []}))
with patch("librarian.server.run_agent", fake):
response = client.post("/api/query", json={"query": "test"})
assert response.status_code == 200
assert "text/event-stream" in response.headers["content-type"]

def test_session_id_header_echoed_when_provided(self):
fake = _make_fake_run_agent(("answer", {"answer": "ok", "references": []}))
with patch("librarian.server.run_agent", fake):
response = client.post(
"/api/query", json={"query": "test", "session_id": "my-sess"}
)
assert response.headers.get("x-session-id") == "my-sess"

def test_session_id_generated_when_absent(self):
fake = _make_fake_run_agent(("answer", {"answer": "ok", "references": []}))
with patch("librarian.server.run_agent", fake):
response = client.post("/api/query", json={"query": "test"})
assert response.headers.get("x-session-id")

def test_missing_query_returns_422(self):
response = client.post("/api/query", json={})
assert response.status_code == 422

def test_streamed_body_contains_sse_events(self):
fake = _make_fake_run_agent(
("node", {"node_type": "RouterNode", "data": {}}),
("answer", {"answer": "42", "references": []}),
)
with patch("librarian.server.run_agent", fake):
response = client.post("/api/query", json={"query": "What is 2+2?"})
assert "event: node" in response.text
assert "event: answer" in response.text


# ---------------------------------------------------------------------------
# GET /api/health
# ---------------------------------------------------------------------------


class TestHealthEndpoint:
def _mock_client(self, status_code: int):
mock_resp = MagicMock()
mock_resp.status_code = status_code
mock_async_client = AsyncMock()
mock_async_client.get = AsyncMock(return_value=mock_resp)
return mock_async_client

def test_returns_vllm_true_when_reachable(self, monkeypatch):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

monkeypatch parameter is declared but never used in this test

mock_client = self._mock_client(status_code=200)
with patch("httpx.AsyncClient") as MockClass:
MockClass.return_value.__aenter__ = AsyncMock(return_value=mock_client)
MockClass.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["vllm"] is True

def test_returns_vllm_false_when_unreachable(self):
with patch("httpx.AsyncClient") as MockClass:
MockClass.return_value.__aenter__ = AsyncMock(
side_effect=Exception("Connection refused")
)
MockClass.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get("/api/health")
assert response.status_code == 200
assert response.json()["vllm"] is False

def test_returns_vllm_false_on_500(self):
mock_client = self._mock_client(status_code=500)
with patch("httpx.AsyncClient") as MockClass:
MockClass.return_value.__aenter__ = AsyncMock(return_value=mock_client)
MockClass.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get("/api/health")
assert response.json()["vllm"] is False

def test_vllm_url_in_response(self, monkeypatch):
monkeypatch.setenv("VLLM_BASE_URL", "http://test-vllm:8080/v1")
mock_client = self._mock_client(status_code=200)
with patch("httpx.AsyncClient") as MockClass:
MockClass.return_value.__aenter__ = AsyncMock(return_value=mock_client)
MockClass.return_value.__aexit__ = AsyncMock(return_value=None)
response = client.get("/api/health")
assert response.json()["vllm_url"] == "http://test-vllm:8080/v1"
61 changes: 61 additions & 0 deletions api/tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Tests for librarian/_tools.py — pure utility functions.

Heavy I/O functions (search_web, search_papers, read_url, get_md) require
external services (Serper API, Playwright, ChromaDB) and are out of scope
for this unit-test suite.
"""

from librarian._tools import build_url_restricted_query


class TestBuildUrlRestrictedQuery:
"""build_url_restricted_query prepends site: filters to a search query."""

def test_no_allowed_urls_returns_query_unchanged(self):
result = build_url_restricted_query("python async", None)
assert result == "python async"

def test_empty_allowed_urls_returns_query_unchanged(self):
result = build_url_restricted_query("python async", [])
assert result == "python async"

def test_single_url_adds_site_filter(self):
result = build_url_restricted_query("deep learning", ["https://arxiv.org"])
assert "site:arxiv.org" in result
assert "deep learning" in result

def test_strips_www_prefix(self):
result = build_url_restricted_query("search query", ["https://www.example.com"])
assert "site:example.com" in result
assert "site:www.example.com" not in result

def test_multiple_urls_joined_with_or(self):
result = build_url_restricted_query(
"query", ["https://arxiv.org", "https://nature.com"]
)
assert "site:arxiv.org" in result
assert "site:nature.com" in result
assert " OR " in result
assert "query" in result

def test_duplicate_urls_deduplicated(self):
result = build_url_restricted_query(
"q", ["https://example.com", "https://example.com"]
)
# Only one site: filter should appear
assert result.count("site:example.com") == 1

def test_url_with_path_included(self):
result = build_url_restricted_query("search", ["https://example.com/papers"])
assert "site:example.com/papers" in result

def test_url_with_www_and_path(self):
result = build_url_restricted_query("q", ["https://www.example.com/blog"])
assert "site:example.com/blog" in result
assert "www." not in result

def test_query_appended_after_filters(self):
result = build_url_restricted_query("machine learning", ["https://example.com"])
# The query text should come after the site filters
filter_end = result.index("machine learning")
assert filter_end > 0
Loading