|
| 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