Skip to content

Commit 07e07c2

Browse files
tradewithmeairajkumarsakthivel
authored andcommitted
Add tests for POST /search and address review follow-ups
Covers the input handling added in the previous round, which had no test coverage anywhere in the suite: - happy path: valid query returns a serialised results list, and the requested top_k actually reaches the retriever - empty / whitespace-only / missing query -> 400 - query over _MAX_QUERY_CHARS -> 400, and exactly at the limit -> 200 - non-numeric top_k and confidence_threshold -> 400 (never a 500) - top_k clamps to 1..100 and confidence_threshold to 0.0..1.0 - omitted params fall back to 10 / 0.2 - the query is stripped before use - an empty result set is 200, not an error Each bad-request test also asserts the retriever was never called, so a rejection cannot silently embed the offending query first. The retriever is stubbed, so the tests need no backend, embedder or index and run in well under a second. The stub records its call arguments, which is what lets the clamping be asserted at the boundary rather than merely smoke-tested. Verified by mutation: removing the empty-query guard fails 3 tests, disabling the length guard fails 1, removing top_k clamping fails 4, and dropping the .strip() fails 2. Also addresses the two minor points from the same review: - confidence_score: drop the getattr(..., None) fallback. Chunk defines the field with a 0.0 default and it is always set, so the None branch was unreachable. - document that /search hardcodes the 0.2 default and therefore ignores retrieval_confidence_threshold from .context-engine.yaml, which the MCP handler honours. Left as a follow-up rather than widening this PR. Suite unchanged otherwise: the pre-existing 5 failures / 5 errors in the http-and-search subset are identical with and without this commit (105 -> 130 passed, the 25 added here).
1 parent eaa46b5 commit 07e07c2

2 files changed

Lines changed: 226 additions & 1 deletion

File tree

src/context_engine/serve_http.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ async def handle_search(self, request: web.Request) -> web.Response:
104104
{"error": f"query too long (max {_MAX_QUERY_CHARS} characters)"},
105105
status=400,
106106
)
107+
# NOTE: the 0.2 default below is hardcoded, so a project overriding
108+
# `retrieval_confidence_threshold` in .context-engine.yaml is honoured by the MCP
109+
# context_search handler but NOT here. Deliberate for v1 — wiring config through
110+
# is a follow-up, not a widening of this PR.
107111
# Validate + clamp: non-numeric input would otherwise raise ValueError and
108112
# surface as a 400 "missing field" via the generic handler; clamp to the same
109113
# ranges the MCP context_search handler uses.
@@ -132,7 +136,7 @@ async def handle_search(self, request: web.Request) -> web.Response:
132136
"content": c.content,
133137
"chunk_type": c.chunk_type.value,
134138
"language": c.language,
135-
"confidence_score": getattr(c, "confidence_score", None),
139+
"confidence_score": c.confidence_score,
136140
"metadata": c.metadata,
137141
}
138142
for c in chunks

tests/test_serve_http_search.py

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
"""Tests for POST /search on the loopback HTTP server (PR #96).
2+
3+
The endpoint is a thin wrapper around HybridRetriever, so what needs covering is
4+
not the retrieval itself (tested elsewhere) but the input handling added in
5+
review: empty query, over-length query, and non-numeric top_k /
6+
confidence_threshold all return 400 rather than raising.
7+
8+
The retriever is stubbed so these tests stay hermetic — no embedder, no backend,
9+
no index. That also lets the happy-path test assert the clamped values actually
10+
reach retrieve(), which is the part a smoke test would miss.
11+
"""
12+
from __future__ import annotations
13+
14+
import json
15+
16+
import pytest
17+
from aiohttp import web
18+
from aiohttp.test_utils import make_mocked_request
19+
20+
from context_engine.models import Chunk, ChunkType
21+
from context_engine.serve_http import _MAX_QUERY_CHARS, ContextEngineHTTP
22+
23+
24+
class _StubRetriever:
25+
"""Records the arguments it was called with and returns one fixed chunk."""
26+
27+
def __init__(self, chunks=None):
28+
self.chunks = chunks if chunks is not None else []
29+
self.calls: list[dict] = []
30+
31+
async def retrieve(self, query, top_k=10, confidence_threshold=0.2):
32+
self.calls.append(
33+
{"query": query, "top_k": top_k, "confidence_threshold": confidence_threshold}
34+
)
35+
return self.chunks
36+
37+
38+
def _chunk() -> Chunk:
39+
return Chunk(
40+
id="b1294739d28245a1",
41+
content="def record_usage(model, provider, input_tokens, ...)",
42+
chunk_type=ChunkType.FUNCTION,
43+
file_path="memory/journal.py",
44+
start_line=81,
45+
end_line=86,
46+
language="python",
47+
metadata={"_distance": 0.774},
48+
confidence_score=0.878,
49+
)
50+
51+
52+
def _server(chunks=None) -> ContextEngineHTTP:
53+
"""A ContextEngineHTTP with its retriever swapped for the stub.
54+
55+
__init__ builds a real HybridRetriever from a backend and embedder, neither of
56+
which these tests need, so the instance is created without running __init__ and
57+
only the attribute handle_search touches is set.
58+
"""
59+
server = ContextEngineHTTP.__new__(ContextEngineHTTP)
60+
server.retriever = _StubRetriever(chunks)
61+
return server
62+
63+
64+
def _request(payload) -> web.Request:
65+
"""A mocked POST /search carrying `payload` as its JSON body."""
66+
body = json.dumps(payload).encode()
67+
req = make_mocked_request(
68+
"POST", "/search",
69+
headers={"Content-Type": "application/json"},
70+
payload=body,
71+
)
72+
73+
async def _json(*args, **kwargs):
74+
return json.loads(body)
75+
76+
req.json = _json
77+
return req
78+
79+
80+
def _body(response: web.Response) -> dict:
81+
return json.loads(response.text)
82+
83+
84+
@pytest.mark.asyncio
85+
async def test_search_happy_path_returns_results():
86+
"""A valid query returns 200 and a serialised results list."""
87+
server = _server([_chunk()])
88+
resp = await server.handle_search(_request({"query": "cost tracking", "top_k": 5}))
89+
90+
assert resp.status == 200
91+
results = _body(resp)["results"]
92+
assert len(results) == 1
93+
94+
r = results[0]
95+
assert r["id"] == "b1294739d28245a1"
96+
assert r["file_path"] == "memory/journal.py"
97+
assert r["start_line"] == 81
98+
assert r["end_line"] == 86
99+
assert r["chunk_type"] == "function" # the enum is serialised by .value
100+
assert r["language"] == "python"
101+
assert r["confidence_score"] == pytest.approx(0.878)
102+
assert r["metadata"] == {"_distance": 0.774}
103+
104+
# top_k must reach the retriever, not just be accepted and dropped.
105+
assert server.retriever.calls == [
106+
{"query": "cost tracking", "top_k": 5, "confidence_threshold": 0.2}
107+
]
108+
109+
110+
@pytest.mark.asyncio
111+
async def test_search_empty_results_is_still_200():
112+
"""No matches is a valid answer, not an error."""
113+
server = _server([])
114+
resp = await server.handle_search(_request({"query": "nothing matches this"}))
115+
116+
assert resp.status == 200
117+
assert _body(resp)["results"] == []
118+
119+
120+
@pytest.mark.asyncio
121+
@pytest.mark.parametrize("query", ["", " ", None])
122+
async def test_search_empty_query_returns_400(query):
123+
"""Empty, whitespace-only and missing queries are all rejected."""
124+
server = _server([_chunk()])
125+
resp = await server.handle_search(_request({"query": query}))
126+
127+
assert resp.status == 400
128+
assert "empty" in _body(resp)["error"]
129+
assert server.retriever.calls == [], "retriever must not be reached on a bad request"
130+
131+
132+
@pytest.mark.asyncio
133+
async def test_search_over_length_query_returns_400():
134+
"""A query past _MAX_QUERY_CHARS is rejected before it is embedded."""
135+
server = _server([_chunk()])
136+
resp = await server.handle_search(_request({"query": "x" * (_MAX_QUERY_CHARS + 1)}))
137+
138+
assert resp.status == 400
139+
assert "too long" in _body(resp)["error"]
140+
assert server.retriever.calls == []
141+
142+
143+
@pytest.mark.asyncio
144+
async def test_search_at_max_query_length_is_accepted():
145+
"""The limit is inclusive — exactly _MAX_QUERY_CHARS must pass."""
146+
server = _server([])
147+
resp = await server.handle_search(_request({"query": "x" * _MAX_QUERY_CHARS}))
148+
149+
assert resp.status == 200
150+
151+
152+
@pytest.mark.asyncio
153+
@pytest.mark.parametrize(
154+
"payload",
155+
[
156+
{"query": "ok", "top_k": "abc"},
157+
{"query": "ok", "top_k": None},
158+
{"query": "ok", "top_k": [5]},
159+
{"query": "ok", "confidence_threshold": "high"},
160+
{"query": "ok", "confidence_threshold": {}},
161+
],
162+
)
163+
async def test_search_non_numeric_params_return_400(payload):
164+
"""Non-numeric top_k / confidence_threshold return 400, never a 500."""
165+
server = _server([_chunk()])
166+
resp = await server.handle_search(_request(payload))
167+
168+
assert resp.status == 400
169+
assert "top_k" in _body(resp)["error"]
170+
assert server.retriever.calls == []
171+
172+
173+
@pytest.mark.asyncio
174+
@pytest.mark.parametrize(
175+
"given, expected",
176+
[(0, 1), (-5, 1), (1, 1), (100, 100), (101, 100), (10_000, 100)],
177+
)
178+
async def test_search_top_k_is_clamped(given, expected):
179+
"""top_k clamps to 1..100 rather than being rejected or passed through."""
180+
server = _server([])
181+
resp = await server.handle_search(_request({"query": "ok", "top_k": given}))
182+
183+
assert resp.status == 200
184+
assert server.retriever.calls[0]["top_k"] == expected
185+
186+
187+
@pytest.mark.asyncio
188+
@pytest.mark.parametrize(
189+
"given, expected",
190+
[(-1.0, 0.0), (0.0, 0.0), (0.5, 0.5), (1.0, 1.0), (2.5, 1.0)],
191+
)
192+
async def test_search_confidence_threshold_is_clamped(given, expected):
193+
"""confidence_threshold clamps to 0.0..1.0."""
194+
server = _server([])
195+
resp = await server.handle_search(
196+
_request({"query": "ok", "confidence_threshold": given})
197+
)
198+
199+
assert resp.status == 200
200+
assert server.retriever.calls[0]["confidence_threshold"] == pytest.approx(expected)
201+
202+
203+
@pytest.mark.asyncio
204+
async def test_search_defaults_when_params_omitted():
205+
"""Omitted params fall back to top_k=10, confidence_threshold=0.2."""
206+
server = _server([])
207+
resp = await server.handle_search(_request({"query": "ok"}))
208+
209+
assert resp.status == 200
210+
assert server.retriever.calls[0]["top_k"] == 10
211+
assert server.retriever.calls[0]["confidence_threshold"] == pytest.approx(0.2)
212+
213+
214+
@pytest.mark.asyncio
215+
async def test_search_query_is_stripped():
216+
"""Surrounding whitespace is trimmed before the query reaches the retriever."""
217+
server = _server([])
218+
resp = await server.handle_search(_request({"query": " cost tracking "}))
219+
220+
assert resp.status == 200
221+
assert server.retriever.calls[0]["query"] == "cost tracking"

0 commit comments

Comments
 (0)