**retain_closets.py** — 90-day closet aging enforcement for #1083. Removes *.closet.json files older than --days (default 90) from the fleet palace. Supports --dry-run for safe preview. Wired into the weekly-audit workflow as a dry-run CI step; production cron guidance added to workflow comments. **tunnel_sync.py** — remote wizard wing pull client for #1078. Connects to a peer's fleet_api.py HTTP endpoint, discovers wings via /wings, and pulls core rooms via /search into local *.closet.json files. Zero new dependencies (stdlib urllib only). Supports --dry-run. This is the code side of the inter-wizard tunnel; infrastructure (second wizard VPS + fleet_api.py running) still required. **Tests:** 29 new tests, all passing. Total suite: 294 passing. Refs #1075, #1078, #1083
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""
|
|
Tests for mempalace/tunnel_sync.py — remote wizard wing sync client.
|
|
|
|
Refs: #1078, #1075
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from mempalace.tunnel_sync import (
|
|
SyncResult,
|
|
_peer_url,
|
|
_write_closet,
|
|
get_remote_wings,
|
|
search_remote_room,
|
|
sync_peer,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _peer_url
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_peer_url_strips_trailing_slash():
|
|
assert _peer_url("http://host:7771/", "/wings") == "http://host:7771/wings"
|
|
|
|
|
|
def test_peer_url_with_path():
|
|
assert _peer_url("http://host:7771", "/search") == "http://host:7771/search"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_remote_wings
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_get_remote_wings_returns_list():
|
|
with patch("mempalace.tunnel_sync._get", return_value={"wings": ["bezalel", "timmy"]}):
|
|
wings = get_remote_wings("http://peer:7771")
|
|
assert wings == ["bezalel", "timmy"]
|
|
|
|
|
|
def test_get_remote_wings_empty():
|
|
with patch("mempalace.tunnel_sync._get", return_value={"wings": []}):
|
|
wings = get_remote_wings("http://peer:7771")
|
|
assert wings == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# search_remote_room
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_entry(text: str, room: str = "forge", wing: str = "bezalel", score: float = 0.9) -> dict:
|
|
return {"text": text, "room": room, "wing": wing, "score": score}
|
|
|
|
|
|
def test_search_remote_room_deduplicates():
|
|
entry = _make_entry("CI passed")
|
|
# Same entry returned from multiple queries — should only appear once
|
|
with patch("mempalace.tunnel_sync._get", return_value={"results": [entry]}):
|
|
results = search_remote_room("http://peer:7771", "forge", n=50)
|
|
assert len(results) == 1
|
|
assert results[0]["text"] == "CI passed"
|
|
|
|
|
|
def test_search_remote_room_respects_n_limit():
|
|
entries = [_make_entry(f"item {i}") for i in range(100)]
|
|
with patch("mempalace.tunnel_sync._get", return_value={"results": entries}):
|
|
results = search_remote_room("http://peer:7771", "forge", n=5)
|
|
assert len(results) <= 5
|
|
|
|
|
|
def test_search_remote_room_handles_request_error():
|
|
import urllib.error
|
|
with patch("mempalace.tunnel_sync._get", side_effect=urllib.error.URLError("refused")):
|
|
results = search_remote_room("http://peer:7771", "forge")
|
|
assert results == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _write_closet
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_write_closet_creates_file(tmp_path):
|
|
entries = [_make_entry("a memory")]
|
|
ok = _write_closet(tmp_path, "bezalel", "forge", entries, dry_run=False)
|
|
assert ok is True
|
|
closet = tmp_path / "bezalel" / "forge.closet.json"
|
|
assert closet.exists()
|
|
data = json.loads(closet.read_text())
|
|
assert data["wing"] == "bezalel"
|
|
assert data["room"] == "forge"
|
|
assert len(data["drawers"]) == 1
|
|
assert data["drawers"][0]["closet"] is True
|
|
assert data["drawers"][0]["text"] == "a memory"
|
|
|
|
|
|
def test_write_closet_dry_run_does_not_create(tmp_path):
|
|
entries = [_make_entry("a memory")]
|
|
ok = _write_closet(tmp_path, "bezalel", "forge", entries, dry_run=True)
|
|
assert ok is True
|
|
closet = tmp_path / "bezalel" / "forge.closet.json"
|
|
assert not closet.exists()
|
|
|
|
|
|
def test_write_closet_creates_wing_subdirectory(tmp_path):
|
|
entries = [_make_entry("memory")]
|
|
_write_closet(tmp_path, "timmy", "hermes", entries, dry_run=False)
|
|
assert (tmp_path / "timmy").is_dir()
|
|
|
|
|
|
def test_write_closet_source_file_is_tunnel_tagged(tmp_path):
|
|
entries = [_make_entry("memory")]
|
|
_write_closet(tmp_path, "bezalel", "hermes", entries, dry_run=False)
|
|
closet = tmp_path / "bezalel" / "hermes.closet.json"
|
|
data = json.loads(closet.read_text())
|
|
assert data["drawers"][0]["source_file"].startswith("tunnel:")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# sync_peer
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _mock_get_responses(peer_url: str) -> dict:
|
|
"""Minimal mock _get returning health, wings, and search results."""
|
|
def _get(url: str) -> dict:
|
|
if url.endswith("/health"):
|
|
return {"status": "ok", "palace": "/var/lib/mempalace/fleet"}
|
|
if url.endswith("/wings"):
|
|
return {"wings": ["bezalel"]}
|
|
if "/search" in url:
|
|
return {"results": [_make_entry("test memory")]}
|
|
return {}
|
|
return _get
|
|
|
|
|
|
def test_sync_peer_writes_closets(tmp_path):
|
|
(tmp_path / ".gitkeep").touch() # ensure palace dir exists
|
|
|
|
with patch("mempalace.tunnel_sync._get", side_effect=_mock_get_responses("http://peer:7771")):
|
|
result = sync_peer("http://peer:7771", tmp_path, n_results=10)
|
|
|
|
assert result.ok
|
|
assert "bezalel" in result.wings_found
|
|
assert result.closets_written > 0
|
|
|
|
|
|
def test_sync_peer_dry_run_no_files(tmp_path):
|
|
(tmp_path / ".gitkeep").touch()
|
|
|
|
with patch("mempalace.tunnel_sync._get", side_effect=_mock_get_responses("http://peer:7771")):
|
|
result = sync_peer("http://peer:7771", tmp_path, n_results=10, dry_run=True)
|
|
|
|
assert result.ok
|
|
# No closet files should be written
|
|
closets = list(tmp_path.rglob("*.closet.json"))
|
|
assert closets == []
|
|
|
|
|
|
def test_sync_peer_unreachable_returns_error(tmp_path):
|
|
import urllib.error
|
|
with patch("mempalace.tunnel_sync._get", side_effect=urllib.error.URLError("refused")):
|
|
result = sync_peer("http://unreachable:7771", tmp_path)
|
|
|
|
assert not result.ok
|
|
assert any("unreachable" in e or "refused" in e for e in result.errors)
|
|
|
|
|
|
def test_sync_peer_unhealthy_returns_error(tmp_path):
|
|
with patch("mempalace.tunnel_sync._get", return_value={"status": "degraded"}):
|
|
result = sync_peer("http://peer:7771", tmp_path)
|
|
|
|
assert not result.ok
|
|
assert any("unhealthy" in e for e in result.errors)
|
|
|
|
|
|
def test_sync_peer_no_wings_is_ok(tmp_path):
|
|
def _get(url: str) -> dict:
|
|
if "/health" in url:
|
|
return {"status": "ok"}
|
|
return {"wings": []}
|
|
|
|
with patch("mempalace.tunnel_sync._get", side_effect=_get):
|
|
result = sync_peer("http://peer:7771", tmp_path)
|
|
|
|
assert result.ok
|
|
assert result.closets_written == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SyncResult.ok
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sync_result_ok_no_errors():
|
|
r = SyncResult(wings_found=["bezalel"], rooms_pulled=5, closets_written=5)
|
|
assert r.ok is True
|
|
|
|
|
|
def test_sync_result_not_ok_with_errors():
|
|
r = SyncResult(errors=["connection refused"])
|
|
assert r.ok is False
|