Skip to content

Commit 79eee64

Browse files
SkylarKeltyCopilot
andcommitted
test: improve coverage from 63 to 197 tests
Add comprehensive test coverage for: - Config parsing helpers (_parse_int, _parse_float, _validate_url, etc.) - LLM chat_completion (errors, think-block stripping, model splitting) - Summarizer module - Searcher domain helpers and error paths - Researcher orchestration (deep_research, select_relevant_results, etc.) - API endpoints (health, root, streaming, auth edge cases, _build_summary) - Pydantic model validation and computed fields Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 65623d0 commit 79eee64

File tree

7 files changed

+1447
-0
lines changed

7 files changed

+1447
-0
lines changed

tests/test_api_extended.py

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
"""Extended API endpoint tests — health, root, streaming, auth, build_summary."""
2+
3+
import json
4+
import time
5+
import unittest
6+
from unittest.mock import AsyncMock, patch, MagicMock
7+
8+
from fastapi.testclient import TestClient
9+
10+
from artemis.config import Settings
11+
from artemis.errors import UpstreamServiceError
12+
from artemis.main import (
13+
_build_summary,
14+
_result_items,
15+
_message_output,
16+
app,
17+
summary_circuit,
18+
)
19+
from artemis.models import (
20+
DeepResearchRun,
21+
SearchResult,
22+
TokenUsage,
23+
)
24+
25+
26+
def _default_settings(**overrides) -> Settings:
27+
defaults = dict(
28+
searxng_api_base="http://localhost:8888",
29+
searxng_timeout_seconds=30.0,
30+
litellm_base_url="https://api.openai.com/v1",
31+
litellm_api_key=None,
32+
llm_timeout_seconds=60.0,
33+
summary_model="arc:apex",
34+
summary_max_tokens=1024,
35+
enable_summary=True,
36+
deep_research_stages=2,
37+
deep_research_passes=1,
38+
deep_research_subqueries=5,
39+
deep_research_results_per_query=10,
40+
deep_research_max_tokens=4000,
41+
deep_research_content_extraction=True,
42+
deep_research_pages_per_section=3,
43+
deep_research_content_max_chars=3000,
44+
allowed_origins=tuple(),
45+
artemis_api_key=None,
46+
log_level="INFO",
47+
)
48+
defaults.update(overrides)
49+
return Settings(**defaults)
50+
51+
52+
class RootAndHealthTestCase(unittest.TestCase):
53+
def setUp(self) -> None:
54+
summary_circuit.consecutive_failures = 0
55+
summary_circuit.opened_until = 0.0
56+
self.client = TestClient(app)
57+
58+
def tearDown(self) -> None:
59+
self.client.close()
60+
61+
def test_root_returns_message(self) -> None:
62+
response = self.client.get("/")
63+
self.assertEqual(response.status_code, 200)
64+
self.assertIn("message", response.json())
65+
self.assertIn("Artemis", response.json()["message"])
66+
67+
def test_health_returns_status(self) -> None:
68+
response = self.client.get("/health")
69+
self.assertEqual(response.status_code, 200)
70+
body = response.json()
71+
self.assertEqual(body["status"], "healthy")
72+
self.assertIn("summary_enabled", body)
73+
self.assertIn("auth_enabled", body)
74+
self.assertIn("summary_circuit_open", body)
75+
76+
def test_health_reports_circuit_open(self) -> None:
77+
summary_circuit.opened_until = time.time() + 300
78+
response = self.client.get("/health")
79+
self.assertTrue(response.json()["summary_circuit_open"])
80+
81+
def test_health_reports_circuit_closed(self) -> None:
82+
summary_circuit.opened_until = 0.0
83+
response = self.client.get("/health")
84+
self.assertFalse(response.json()["summary_circuit_open"])
85+
86+
87+
class AuthEdgeCasesTestCase(unittest.TestCase):
88+
def setUp(self) -> None:
89+
self.client = TestClient(app)
90+
91+
def tearDown(self) -> None:
92+
self.client.close()
93+
94+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
95+
def test_wrong_token_rejected(self, mock_search: AsyncMock) -> None:
96+
mock_search.return_value = []
97+
with patch("artemis.main.get_settings", return_value=_default_settings(artemis_api_key="correct")):
98+
response = self.client.post(
99+
"/search",
100+
json={"query": "test"},
101+
headers={"Authorization": "Bearer wrong-token"},
102+
)
103+
self.assertEqual(response.status_code, 401)
104+
105+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
106+
def test_no_auth_header_rejected(self, mock_search: AsyncMock) -> None:
107+
mock_search.return_value = []
108+
with patch("artemis.main.get_settings", return_value=_default_settings(artemis_api_key="secret")):
109+
response = self.client.post("/search", json={"query": "test"})
110+
self.assertEqual(response.status_code, 401)
111+
112+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
113+
def test_no_api_key_configured_allows_access(self, mock_search: AsyncMock) -> None:
114+
mock_search.return_value = []
115+
with patch("artemis.main.get_settings", return_value=_default_settings(artemis_api_key=None)):
116+
response = self.client.post("/search", json={"query": "test"})
117+
self.assertEqual(response.status_code, 200)
118+
119+
120+
class ResultItemsHelperTestCase(unittest.TestCase):
121+
def test_converts_search_results(self) -> None:
122+
results = [
123+
SearchResult(title="T1", url="https://a.com", snippet="S1", date="2025-01-01"),
124+
SearchResult(title="T2", url="https://b.com", snippet="S2"),
125+
]
126+
items = _result_items(results)
127+
self.assertEqual(len(items), 2)
128+
self.assertEqual(items[0].id, 0)
129+
self.assertEqual(items[0].url, "https://a.com")
130+
self.assertEqual(items[0].date, "2025-01-01")
131+
self.assertEqual(items[1].id, 1)
132+
self.assertIsNone(items[1].date)
133+
134+
def test_empty_results(self) -> None:
135+
self.assertEqual(_result_items([]), [])
136+
137+
138+
class MessageOutputHelperTestCase(unittest.TestCase):
139+
def test_creates_message(self) -> None:
140+
msg = _message_output("Hello world")
141+
self.assertEqual(msg.role, "assistant")
142+
self.assertEqual(msg.status, "completed")
143+
self.assertEqual(len(msg.content), 1)
144+
self.assertEqual(msg.content[0].text, "Hello world")
145+
self.assertIsNotNone(msg.id)
146+
147+
148+
class BuildSummaryTestCase(unittest.IsolatedAsyncioTestCase):
149+
def setUp(self) -> None:
150+
summary_circuit.consecutive_failures = 0
151+
summary_circuit.opened_until = 0.0
152+
153+
@patch("artemis.main.summarize_results", new_callable=AsyncMock)
154+
async def test_returns_summary_on_success(self, mock_summarize: AsyncMock) -> None:
155+
mock_summarize.return_value = {
156+
"summary": "Great summary",
157+
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
158+
}
159+
results = [SearchResult(title="T", url="https://a.com", snippet="S")]
160+
161+
summary, usage, warnings = await _build_summary("test", results)
162+
163+
self.assertEqual(summary, "Great summary")
164+
self.assertIsNotNone(usage)
165+
self.assertEqual(warnings, [])
166+
167+
async def test_returns_none_when_summary_disabled(self) -> None:
168+
with patch("artemis.main.get_settings", return_value=_default_settings(enable_summary=False)):
169+
results = [SearchResult(title="T", url="https://a.com", snippet="S")]
170+
summary, usage, warnings = await _build_summary("test", results)
171+
self.assertIsNone(summary)
172+
self.assertEqual(warnings, [])
173+
174+
async def test_returns_none_when_no_results(self) -> None:
175+
summary, usage, warnings = await _build_summary("test", [])
176+
self.assertIsNone(summary)
177+
178+
async def test_returns_none_when_circuit_open(self) -> None:
179+
summary_circuit.opened_until = time.time() + 300
180+
results = [SearchResult(title="T", url="https://a.com", snippet="S")]
181+
summary, usage, warnings = await _build_summary("test", results)
182+
self.assertIsNone(summary)
183+
self.assertGreater(len(warnings), 0)
184+
self.assertIn("temporarily disabled", warnings[0])
185+
186+
@patch("artemis.main.summarize_results", new_callable=AsyncMock)
187+
async def test_records_failure_on_error(self, mock_summarize: AsyncMock) -> None:
188+
mock_summarize.side_effect = UpstreamServiceError("LLM down")
189+
results = [SearchResult(title="T", url="https://a.com", snippet="S")]
190+
191+
summary, usage, warnings = await _build_summary("test", results)
192+
193+
self.assertIsNone(summary)
194+
self.assertGreater(len(warnings), 0)
195+
self.assertEqual(summary_circuit.consecutive_failures, 1)
196+
197+
198+
class SearchEndpointUsageTestCase(unittest.TestCase):
199+
"""Test that /search enriches usage with search-specific metrics."""
200+
201+
def setUp(self) -> None:
202+
summary_circuit.consecutive_failures = 0
203+
summary_circuit.opened_until = 0.0
204+
self.client = TestClient(app)
205+
206+
def tearDown(self) -> None:
207+
self.client.close()
208+
209+
@patch("artemis.main.summarize_results", new_callable=AsyncMock)
210+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
211+
def test_search_includes_usage_metrics(
212+
self, mock_search: AsyncMock, mock_summarize: AsyncMock
213+
) -> None:
214+
mock_search.return_value = [
215+
SearchResult(title="Test Result", url="https://example.com", snippet="A test snippet"),
216+
]
217+
mock_summarize.return_value = {
218+
"summary": "Summary text",
219+
"usage": {"input_tokens": 50, "output_tokens": 20, "total_tokens": 70},
220+
}
221+
222+
response = self.client.post("/search", json={"query": "test"})
223+
body = response.json()
224+
usage = body["usage"]
225+
226+
self.assertEqual(usage["search_requests"], 1)
227+
self.assertGreater(usage["citation_tokens"], 0)
228+
self.assertEqual(usage["prompt_tokens"], 50)
229+
self.assertEqual(usage["completion_tokens"], 20)
230+
231+
232+
class ResponsesEndpointTestCase(unittest.TestCase):
233+
def setUp(self) -> None:
234+
summary_circuit.consecutive_failures = 0
235+
summary_circuit.opened_until = 0.0
236+
self.client = TestClient(app)
237+
238+
def tearDown(self) -> None:
239+
self.client.close()
240+
241+
@patch("artemis.main.summarize_results", new_callable=AsyncMock)
242+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
243+
def test_fast_search_response_structure(
244+
self, mock_search: AsyncMock, mock_summarize: AsyncMock
245+
) -> None:
246+
mock_search.return_value = [
247+
SearchResult(title="R1", url="https://a.com", snippet="S1"),
248+
]
249+
mock_summarize.return_value = {"summary": "Summary", "usage": None}
250+
251+
response = self.client.post("/v1/responses", json={"input": "test"})
252+
body = response.json()
253+
254+
self.assertEqual(response.status_code, 200)
255+
self.assertEqual(body["object"], "response")
256+
self.assertEqual(body["model"], "artemis-search")
257+
self.assertEqual(body["status"], "completed")
258+
self.assertEqual(len(body["output"]), 2) # message + search_results
259+
self.assertEqual(body["output"][0]["type"], "message")
260+
self.assertEqual(body["output"][1]["type"], "search_results")
261+
262+
@patch("artemis.main.deep_research", new_callable=AsyncMock)
263+
def test_deep_research_response_structure(self, mock_dr: AsyncMock) -> None:
264+
mock_dr.return_value = DeepResearchRun(
265+
essay="Research essay",
266+
results=[SearchResult(title="R", url="https://r.com", snippet="S")],
267+
sub_queries=["q1"],
268+
stages_completed=2,
269+
usage=TokenUsage(input_tokens=100, output_tokens=200, total_tokens=300),
270+
)
271+
272+
response = self.client.post(
273+
"/v1/responses", json={"input": "topic", "preset": "deep-research"}
274+
)
275+
body = response.json()
276+
277+
self.assertEqual(response.status_code, 200)
278+
self.assertEqual(body["model"], "artemis-deep-research")
279+
self.assertIn("Research essay", body["output"][0]["content"][0]["text"])
280+
self.assertEqual(body["usage"]["total_tokens"], 300)
281+
282+
283+
class StreamingTestCase(unittest.TestCase):
284+
def setUp(self) -> None:
285+
self.client = TestClient(app)
286+
287+
def tearDown(self) -> None:
288+
self.client.close()
289+
290+
@patch("artemis.main.summarize_results", new_callable=AsyncMock)
291+
@patch("artemis.main.search_searxng", new_callable=AsyncMock)
292+
def test_fast_search_streaming(self, mock_search: AsyncMock, mock_summarize: AsyncMock) -> None:
293+
mock_search.return_value = [
294+
SearchResult(title="R1", url="https://a.com", snippet="Snippet text"),
295+
]
296+
mock_summarize.return_value = {"summary": "Streamed summary", "usage": None}
297+
298+
response = self.client.post(
299+
"/v1/responses", json={"input": "test", "streaming": True}
300+
)
301+
302+
self.assertEqual(response.status_code, 200)
303+
self.assertEqual(response.headers["content-type"], "text/plain; charset=utf-8")
304+
body = response.text
305+
self.assertIn("[Starting research on: test]", body)
306+
self.assertIn("[Searching...]", body)
307+
308+
@patch("artemis.main.deep_research", new_callable=AsyncMock)
309+
def test_deep_research_streaming(self, mock_dr: AsyncMock) -> None:
310+
mock_dr.return_value = DeepResearchRun(
311+
essay="Streamed essay content",
312+
results=[SearchResult(title="R", url="https://r.com", snippet="S")],
313+
sub_queries=["q1"],
314+
stages_completed=1,
315+
usage=TokenUsage(input_tokens=50, output_tokens=100, total_tokens=150),
316+
)
317+
318+
response = self.client.post(
319+
"/v1/responses",
320+
json={"input": "test topic", "preset": "deep-research", "streaming": True},
321+
)
322+
323+
self.assertEqual(response.status_code, 200)
324+
body = response.text
325+
self.assertIn("[Starting research on: test topic]", body)
326+
self.assertIn("Streamed essay content", body)
327+
self.assertIn("[USAGE]", body)
328+
329+
@patch("artemis.main.deep_research", new_callable=AsyncMock)
330+
def test_streaming_usage_is_valid_json(self, mock_dr: AsyncMock) -> None:
331+
usage = TokenUsage(input_tokens=10, output_tokens=20, total_tokens=30, search_requests=2)
332+
mock_dr.return_value = DeepResearchRun(
333+
essay="Essay", results=[], sub_queries=[], stages_completed=1, usage=usage,
334+
)
335+
336+
response = self.client.post(
337+
"/v1/responses",
338+
json={"input": "t", "preset": "deep-research", "streaming": True},
339+
)
340+
341+
# Extract USAGE line and parse it
342+
for line in response.text.split("\n"):
343+
if "[USAGE]" in line:
344+
json_str = line.split("[USAGE] ")[1]
345+
parsed = json.loads(json_str)
346+
self.assertEqual(parsed["input_tokens"], 10)
347+
self.assertEqual(parsed["search_requests"], 2)
348+
break
349+
else:
350+
self.fail("No [USAGE] line found in streaming response")

0 commit comments

Comments
 (0)