MeshChatX/tests/backend/test_map_data_live_link.py

328 lines
11 KiB
Python

# SPDX-License-Identifier: 0BSD
"""Live map-data-v1 catalog fetch over an RNS Link on loopback TCP."""
from __future__ import annotations
import json
import socket
import subprocess
import sys
import textwrap
import time
from pathlib import Path
from tests.backend.eect.harness import eect_scenario
from tests.backend.support.test_temp_dir import subprocess_test_env
_PUB_SCRIPT = textwrap.dedent(
r"""
import json
import os
import sys
import time
import RNS
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.map_data_manager import MapDataManager
GEOJSON = json.dumps(
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"name": "Camp"},
"geometry": {"type": "Point", "coordinates": [1.0, 2.0]},
},
],
},
).encode()
class FakeValue:
def __init__(self, value):
self._value = value
def get(self):
return self._value
def set(self, value):
self._value = value
class FakeConfig:
def __init__(self):
self.map_overlay_max_bytes = FakeValue(8 * 1024 * 1024)
self.map_overlay_max_features = FakeValue(50_000)
self.map_overlay_max_kmz_uncompressed_bytes = FakeValue(16 * 1024 * 1024)
self.map_overlay_path_timeout_seconds = FakeValue(8)
self.map_overlay_transfer_timeout_seconds = FakeValue(20)
self.map_overlay_job_timeout_seconds = FakeValue(25)
self.map_data_max_bytes = FakeValue(512 * 1024)
self.map_data_announce_enabled = FakeValue(False)
self.map_data_announce_interval = FakeValue(900)
self.map_data_display_name = FakeValue("Camp maps")
config_dir, share_dir = sys.argv[1], sys.argv[2]
stop_path = os.path.join(share_dir, "stop")
ready_path = os.path.join(share_dir, "ready.json")
RNS.Reticulum(configdir=config_dir, loglevel=RNS.LOG_ERROR)
identity = RNS.Identity()
db = Database(os.path.join(config_dir, "db.sqlite"))
db.initialize()
mgr = MapDataManager(
FakeConfig(),
db,
os.path.join(config_dir, "store"),
identity,
reticulum=RNS.Reticulum.get_instance(),
)
mgr.start()
if mgr._destination is not None:
raise SystemExit("destination created before publish")
published = mgr.publish_bytes(GEOJSON, name="Camp")
if mgr._destination is None:
raise SystemExit("destination missing after publish")
mgr.announce()
with open(ready_path, "w", encoding="utf-8") as handle:
json.dump(
{
"dest": mgr._destination.hash.hex(),
"pub": identity.get_public_key().hex(),
"map_id": published["map"]["map_id"],
},
handle,
)
deadline = time.time() + 45
while time.time() < deadline and not os.path.isfile(stop_path):
mgr.announce()
time.sleep(1.5)
RNS.exit(0)
"""
)
_CLI_SCRIPT = textwrap.dedent(
r"""
import asyncio
import json
import os
import sys
import time
import RNS
from meshchatx.src.backend.database import Database
from meshchatx.src.backend.map_data_manager import MapDataManager
from meshchatx.src.backend.rns_link_manager import RnsLinkManager
class FakeValue:
def __init__(self, value):
self._value = value
def get(self):
return self._value
def set(self, value):
self._value = value
class FakeConfig:
def __init__(self):
self.map_overlay_max_bytes = FakeValue(8 * 1024 * 1024)
self.map_overlay_max_features = FakeValue(50_000)
self.map_overlay_max_kmz_uncompressed_bytes = FakeValue(16 * 1024 * 1024)
self.map_overlay_path_timeout_seconds = FakeValue(12)
self.map_overlay_transfer_timeout_seconds = FakeValue(20)
self.map_overlay_job_timeout_seconds = FakeValue(25)
self.map_data_max_bytes = FakeValue(512 * 1024)
self.map_data_announce_enabled = FakeValue(False)
self.map_data_announce_interval = FakeValue(900)
self.map_data_display_name = FakeValue("Client")
config_dir, share_dir = sys.argv[1], sys.argv[2]
ready_path = os.path.join(share_dir, "ready.json")
result_path = os.path.join(share_dir, "result.json")
RNS.Reticulum(configdir=config_dir, loglevel=RNS.LOG_ERROR)
identity = RNS.Identity()
deadline = time.time() + 20
ready = None
while time.time() < deadline:
if os.path.isfile(ready_path):
with open(ready_path, encoding="utf-8") as handle:
ready = json.load(handle)
if ready.get("dest"):
break
time.sleep(0.2)
if not ready:
with open(result_path, "w", encoding="utf-8") as handle:
json.dump({"ok": False, "reason": "no_ready"}, handle)
RNS.exit(0)
raise SystemExit(0)
peer = bytes.fromhex(ready["dest"])
RNS.Identity.remember(
RNS.Identity.full_hash(peer),
peer,
bytes.fromhex(ready["pub"]),
)
path_deadline = time.time() + 20
while time.time() < path_deadline:
if RNS.Transport.has_path(peer) and RNS.Identity.recall(peer):
break
RNS.Transport.request_path(peer)
time.sleep(0.4)
db = Database(os.path.join(config_dir, "db.sqlite"))
db.initialize()
link_mgr = RnsLinkManager(
self_identity_getter=lambda: identity,
reticulum_getter=lambda: RNS.Reticulum.get_instance(),
broadcast_event=lambda _payload: None,
)
mgr = MapDataManager(
FakeConfig(),
db,
os.path.join(config_dir, "store"),
identity,
reticulum=RNS.Reticulum.get_instance(),
link_manager_getter=lambda: link_mgr,
)
async def run():
catalog = await mgr.fetch_catalog(peer.hex())
maps = catalog.get("maps") or []
if not maps:
return {"ok": False, "reason": "empty_catalog", "catalog": catalog}
body = await mgr.fetch_map_bytes(peer.hex(), maps[0]["id"])
return {
"ok": True,
"name": maps[0]["name"],
"map_id": maps[0]["id"],
"published_id": ready["map_id"],
"size": len(body),
"has_path": bool(RNS.Transport.has_path(peer)),
}
try:
payload = asyncio.run(run())
except Exception as exc:
payload = {"ok": False, "reason": str(exc)}
with open(result_path, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
RNS.exit(0)
"""
)
def _free_port() -> int:
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
port = int(sock.getsockname()[1])
sock.close()
return port
def _write_pair(pub_dir: Path, cli_dir: Path, port: int) -> None:
pub_dir.mkdir(parents=True, exist_ok=True)
cli_dir.mkdir(parents=True, exist_ok=True)
(pub_dir / "config").write_text(
"[reticulum]\n"
"enable_transport = Yes\n"
"share_instance = No\n"
f"shared_instance_port = {37000 + (port % 1000)}\n"
f"instance_name = map_data_pub_{port}\n"
"panic_on_interface_error = No\n"
"\n"
"[logging]\n"
"loglevel = 3\n"
"\n"
"[interfaces]\n"
" [[TCP Server]]\n"
" type = TCPServerInterface\n"
" enabled = Yes\n"
" listen_ip = 127.0.0.1\n"
f" listen_port = {port}\n",
encoding="utf-8",
)
(cli_dir / "config").write_text(
"[reticulum]\n"
"enable_transport = Yes\n"
"share_instance = No\n"
f"shared_instance_port = {38000 + (port % 1000)}\n"
f"instance_name = map_data_cli_{port}\n"
"panic_on_interface_error = No\n"
"\n"
"[logging]\n"
"loglevel = 3\n"
"\n"
"[interfaces]\n"
" [[TCP Client]]\n"
" type = TCPClientInterface\n"
" enabled = Yes\n"
" target_host = 127.0.0.1\n"
f" target_port = {port}\n",
encoding="utf-8",
)
def test_live_catalog_and_map_bytes_over_rns_link(tmp_path):
with eect_scenario("map.data.live_catalog_link"):
port = _free_port()
pub_dir = tmp_path / "pub"
cli_dir = tmp_path / "cli"
share_dir = tmp_path / "share"
share_dir.mkdir(parents=True, exist_ok=True)
_write_pair(pub_dir, cli_dir, port)
env = subprocess_test_env()
pub = subprocess.Popen(
[sys.executable, "-c", _PUB_SCRIPT, str(pub_dir), str(share_dir)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
cli = None
try:
ready = share_dir / "ready.json"
deadline = time.time() + 25
while time.time() < deadline and not ready.is_file():
if pub.poll() is not None:
stdout, stderr = pub.communicate(timeout=5)
raise AssertionError(
f"publisher exited {pub.returncode}: {stderr}\n{stdout}"
)
time.sleep(0.2)
assert ready.is_file(), "publisher did not write ready.json"
cli = subprocess.Popen(
[sys.executable, "-c", _CLI_SCRIPT, str(cli_dir), str(share_dir)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
)
result_path = share_dir / "result.json"
deadline = time.time() + 40
while time.time() < deadline and not result_path.is_file():
if cli.poll() is not None and not result_path.is_file():
stdout, stderr = cli.communicate(timeout=5)
raise AssertionError(
f"client exited {cli.returncode}: {stderr}\n{stdout}"
)
time.sleep(0.2)
assert result_path.is_file(), "client did not write result.json"
payload = json.loads(result_path.read_text(encoding="utf-8"))
assert payload.get("ok") is True, payload
assert payload["name"] == "Camp"
assert payload["map_id"] == payload["published_id"]
assert payload["size"] > 0
finally:
(share_dir / "stop").write_text("1", encoding="utf-8")
for proc in (cli, pub):
if proc is None:
continue
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)