240 lines
8.0 KiB
Python
240 lines
8.0 KiB
Python
"""
|
|
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)
|
|
)
|