"""End-to-end tests for the Tier-1 localhost HTTP adapter (http_server.py). Mirrors tests/test_daemon_dispatcher.py: boot the REAL HttpServer on an ephemeral 127.0.0.1 port and drive it with raw HTTP/1.0 over a real TCP socket. core.dispatch is monkeypatched to a fake so these tests exercise the *transport adapter* (routing, error->status mapping, port-file discovery, graceful shutdown) without needing a real MemoryStore / LanceDB. """ from __future__ import annotations import asyncio import json import pytest from iai_mcp.core import UnknownMethodError def _fake_dispatch(store, method, params): """Stand-in for core.dispatch: deterministic, no store needed.""" if method == "session_start_payload": return { "l0": "pinned identity", "l1": "recent summary", "l2": ["episode one", "episode two"], "rich_club": "hub concepts", "total_cached_tokens": 0, "total_dynamic_tokens": 1000, # echo the override so the transport-threading test can assert it. "wake_depth": params.get("wake_depth", "minimal"), } if method == "minimal_payload": # wake_depth=minimal: content layers empty, only opaque handles set. return { "l0": "", "l1": "", "l2": [], "rich_club": "", "identity_pointer": "", "brain_handle": "", "topic_cluster_hint": "", "compact_handle": "", "wake_depth": "minimal", } if method == "needs_param": # Simulate core.py's params["cue"] KeyError path. return {"cue": params["cue"]} if method == "boom": raise RuntimeError("kaboom") raise UnknownMethodError(method) async def _http_request(port, method, path, body=None, *, timeout=5.0): """Send one HTTP/1.0 request, read the full response to EOF, parse it.""" reader, writer = await asyncio.wait_for( asyncio.open_connection("127.0.0.1", port), timeout=timeout ) try: body_bytes = body.encode("utf-8") if isinstance(body, str) else (body or b"") head = f"{method} {path} HTTP/1.0\r\n" if body_bytes: head += f"Content-Length: {len(body_bytes)}\r\n" head += "Connection: close\r\n\r\n" writer.write(head.encode("latin-1") + body_bytes) await writer.drain() raw = await asyncio.wait_for(reader.read(-1), timeout=timeout) finally: writer.close() try: await writer.wait_closed() except Exception: pass head_bytes, _, payload = raw.partition(b"\r\n\r\n") lines = head_bytes.split(b"\r\n") status = int(lines[0].decode("latin-1").split(" ")[1]) headers = {} for line in lines[1:]: name, _, val = line.decode("latin-1").partition(":") headers[name.strip().lower()] = val.strip() return status, headers, payload.decode("utf-8") async def _with_http_server(coro_fn, *, port_file, monkeypatch): """Boot the real HttpServer with _fake_dispatch on an ephemeral port.""" from iai_mcp import http_server as hs monkeypatch.setattr(hs, "dispatch", _fake_dispatch) server = hs.HttpServer(store=object(), host="127.0.0.1", port=0, port_file=port_file) task = asyncio.create_task(server.serve()) for _ in range(250): # wait for bind (bound_port set inside serve()) if server.bound_port is not None: break await asyncio.sleep(0.01) if server.bound_port is None: server.shutdown_event.set() await asyncio.wait_for(task, timeout=5) raise AssertionError("server never bound") try: return await coro_fn(server) finally: server.shutdown_event.set() try: await asyncio.wait_for(task, timeout=5) except Exception: pass def test_healthz_returns_ok(tmp_path, monkeypatch): async def _runner(server): return await _http_request(server.bound_port, "GET", "/healthz") status, headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 200 assert headers["content-type"] == "application/json" assert json.loads(body) == {"ok": True} def test_session_context_json(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "GET", "/memory/session-context?session_id=abc" ) status, headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 200 assert headers["content-type"] == "application/json" payload = json.loads(body) assert payload["l0"] == "pinned identity" assert payload["l2"] == ["episode one", "episode two"] def test_session_context_threads_wake_depth(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "GET", "/memory/session-context?session_id=abc&wake_depth=standard", ) status, _headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 200 assert json.loads(body)["wake_depth"] == "standard" def test_session_context_text_format(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "GET", "/memory/session-context?format=text" ) status, headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 200 assert headers["content-type"].startswith("text/plain") # Flattened block keeps the human layers in order, drops token ints. assert body == ( "## L0 Identity\npinned identity\n\n## L1 Recent Summary\nrecent summary\n\n" "## Rich Club\nhub concepts\n\n## L2 Episode\nepisode one\n\n## L2 Episode\nepisode two" ) def test_payload_to_text_renders_handles_at_minimal(): """At minimal wake_depth, _payload_to_text surfaces the compact handles (content layers empty) so the injected block is never silently empty.""" from iai_mcp.http_server import _payload_to_text text = _payload_to_text(_fake_dispatch(None, "minimal_payload", {})) assert text == "## Handles\n " def test_rpc_post_passthrough(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "POST", "/rpc", json.dumps({"method": "session_start_payload", "params": {"session_id": "z"}}), ) status, _headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 200 assert json.loads(body)["rich_club"] == "hub concepts" def test_unknown_route_404(tmp_path, monkeypatch): async def _runner(server): return await _http_request(server.bound_port, "GET", "/nope") status, _headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 404 assert "no route" in json.loads(body)["error"] def test_unknown_method_maps_to_404(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "POST", "/rpc", json.dumps({"method": "ghost"}) ) status, _headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 404 assert "unknown method 'ghost'" in json.loads(body)["error"] def test_internal_error_maps_to_500(tmp_path, monkeypatch): async def _runner(server): return await _http_request( server.bound_port, "POST", "/rpc", json.dumps({"method": "boom"}) ) status, _headers, body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 500 assert "kaboom" in json.loads(body)["error"] def test_wrong_verb_405(tmp_path, monkeypatch): async def _runner(server): return await _http_request(server.bound_port, "POST", "/healthz") status, _headers, _body = asyncio.run( _with_http_server(_runner, port_file=tmp_path / ".http.port", monkeypatch=monkeypatch) ) assert status == 405 def test_port_file_written_and_cleaned(tmp_path, monkeypatch): port_file = tmp_path / ".http.port" async def _runner(server): # While serving, the port-file holds the live bound port. assert port_file.read_text() == str(server.bound_port) return server.bound_port asyncio.run(_with_http_server(_runner, port_file=port_file, monkeypatch=monkeypatch)) # After graceful shutdown, the discovery file is removed. assert not port_file.exists()