""" Tests for mempalace/fleet_api.py — Alpha-side HTTP fleet memory API. Refs: #1078, #1075 """ from __future__ import annotations import io import json import threading from pathlib import Path from unittest.mock import MagicMock, patch import pytest # Import handler directly so we can test without running a server process. from mempalace.fleet_api import FleetAPIHandler, _handle_health, _handle_search, _handle_wings, make_server # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- class _FakeSocket: """Minimal socket stub for BaseHTTPRequestHandler.""" def makefile(self, mode: str, *args, **kwargs): # noqa: ANN001 return io.BytesIO(b"") def _make_handler(path: str = "/health") -> tuple[FleetAPIHandler, io.BytesIO]: """Construct a handler pointed at *path*, capture wfile output.""" buf = io.BytesIO() request = _FakeSocket() client_address = ("127.0.0.1", 0) handler = FleetAPIHandler.__new__(FleetAPIHandler) handler.path = path handler.request = request handler.client_address = client_address handler.server = MagicMock() handler.wfile = buf handler.rfile = io.BytesIO(b"") handler.command = "GET" handler._headers_buffer = [] # Stub send_response / send_header / end_headers to write minimal HTTP handler._response_code = None def _send_response(code, message=None): # noqa: ANN001 handler._response_code = code def _send_header(k, v): # noqa: ANN001 pass def _end_headers(): # noqa: ANN001 pass handler.send_response = _send_response handler.send_header = _send_header handler.end_headers = _end_headers return handler, buf def _parse_response(buf: io.BytesIO) -> dict: buf.seek(0) return json.loads(buf.read()) # --------------------------------------------------------------------------- # /health # --------------------------------------------------------------------------- def test_health_returns_ok(tmp_path, monkeypatch): monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) handler, buf = _make_handler("/health") _handle_health(handler) data = _parse_response(buf) assert data["status"] == "ok" assert data["palace_exists"] is True def test_health_missing_palace(tmp_path, monkeypatch): missing = tmp_path / "nonexistent" monkeypatch.setenv("FLEET_PALACE_PATH", str(missing)) handler, buf = _make_handler("/health") _handle_health(handler) data = _parse_response(buf) assert data["status"] == "ok" assert data["palace_exists"] is False # --------------------------------------------------------------------------- # /search # --------------------------------------------------------------------------- def _mock_search_fleet(results): """Return a patch target that returns *results*.""" mock = MagicMock(return_value=results) return mock def _make_result(text="hello", room="forge", wing="bezalel", score=0.9): from nexus.mempalace.searcher import MemPalaceResult return MemPalaceResult(text=text, room=room, wing=wing, score=score) def test_search_missing_q_param(): handler, buf = _make_handler("/search") _handle_search(handler, {}) data = _parse_response(buf) assert "error" in data assert handler._response_code == 400 def test_search_returns_results(tmp_path, monkeypatch): monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) (tmp_path / "chroma.sqlite3").touch() result = _make_result(text="CI green", room="forge", wing="bezalel", score=0.95) with patch("mempalace.fleet_api.FleetAPIHandler") as _: handler, buf = _make_handler("/search?q=CI") import nexus.mempalace.searcher as s_module with patch.object(s_module, "search_fleet", return_value=[result]): import importlib import mempalace.fleet_api as api_module # Patch search_fleet inside the handler's import context with patch("nexus.mempalace.searcher.search_fleet", return_value=[result]): _handle_search(handler, {"q": ["CI"]}) data = _parse_response(buf) assert data["count"] == 1 assert data["results"][0]["text"] == "CI green" assert data["results"][0]["room"] == "forge" assert data["results"][0]["wing"] == "bezalel" assert data["results"][0]["score"] == 0.95 assert handler._response_code == 200 def test_search_with_room_filter(tmp_path, monkeypatch): monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) result = _make_result() import nexus.mempalace.searcher as s_module with patch.object(s_module, "search_fleet", return_value=[result]) as mock_sf: _handle_search(MagicMock(), {"q": ["test"], "room": ["hermes"]}) # Verify room was passed through mock_sf.assert_called_once_with("test", room="hermes", n_results=10) def test_search_invalid_n_param(): handler, buf = _make_handler("/search?q=test&n=bad") _handle_search(handler, {"q": ["test"], "n": ["bad"]}) data = _parse_response(buf) assert "error" in data assert handler._response_code == 400 def test_search_palace_unavailable(monkeypatch): from nexus.mempalace.searcher import MemPalaceUnavailable handler, buf = _make_handler("/search?q=test") import nexus.mempalace.searcher as s_module with patch.object(s_module, "search_fleet", side_effect=MemPalaceUnavailable("no palace")): _handle_search(handler, {"q": ["test"]}) data = _parse_response(buf) assert "error" in data assert handler._response_code == 503 def test_search_n_clamped_to_max(): """n > MAX_RESULTS is silently clamped.""" import nexus.mempalace.searcher as s_module with patch.object(s_module, "search_fleet", return_value=[]) as mock_sf: handler = MagicMock() _handle_search(handler, {"q": ["test"], "n": ["9999"]}) mock_sf.assert_called_once_with("test", room=None, n_results=50) # --------------------------------------------------------------------------- # /wings # --------------------------------------------------------------------------- def test_wings_returns_list(tmp_path, monkeypatch): monkeypatch.setenv("FLEET_PALACE_PATH", str(tmp_path)) (tmp_path / "bezalel").mkdir() (tmp_path / "timmy").mkdir() # A file should not appear in wings (tmp_path / "README.txt").touch() handler, buf = _make_handler("/wings") _handle_wings(handler) data = _parse_response(buf) assert set(data["wings"]) == {"bezalel", "timmy"} assert handler._response_code == 200 def test_wings_missing_palace(tmp_path, monkeypatch): missing = tmp_path / "nonexistent" monkeypatch.setenv("FLEET_PALACE_PATH", str(missing)) handler, buf = _make_handler("/wings") _handle_wings(handler) data = _parse_response(buf) assert "error" in data assert handler._response_code == 503 # --------------------------------------------------------------------------- # 404 unknown endpoint # --------------------------------------------------------------------------- def test_unknown_endpoint(): handler, buf = _make_handler("/foobar") handler.do_GET() data = _parse_response(buf) assert "error" in data assert handler._response_code == 404 assert "/search" in data["endpoints"] # --------------------------------------------------------------------------- # audit fixture smoke test # --------------------------------------------------------------------------- def test_audit_fixture_is_clean(): """Ensure tests/fixtures/fleet_palace/ passes privacy audit (no violations).""" from mempalace.audit_privacy import audit_palace fixture_dir = Path(__file__).parent / "fixtures" / "fleet_palace" assert fixture_dir.exists(), f"Fixture directory missing: {fixture_dir}" result = audit_palace(fixture_dir) assert result.clean, ( f"Privacy violations found in CI fixture:\n" + "\n".join(f" [{v.rule}] {v.path}: {v.detail}" for v in result.violations) )