-
Notifications
You must be signed in to change notification settings - Fork 0
test: add initial backend test suite #23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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): | ||
| 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" | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
monkeypatchparameter is declared but never used in this test