From 8c945c00f62f36795544e190a1c56d0ef658b26d Mon Sep 17 00:00:00 2001 From: Corvo <60719165+brothercorvo@users.noreply.github.com> Date: Mon, 11 Aug 2025 12:09:45 -0300 Subject: [PATCH] Add loopback link tests --- TASK.md | 1 + reticulum_openapi/link_service.py | 4 +- tests/test_link_client.py | 186 +++++++++++++++++++++++++++++- tests/test_link_resources.py | 5 +- tests/test_link_service.py | 126 ++++++++++++++++++++ 5 files changed, 314 insertions(+), 8 deletions(-) diff --git a/TASK.md b/TASK.md index 9ae7664..8534d9e 100644 --- a/TASK.md +++ b/TASK.md @@ -2,3 +2,4 @@ ## 2025-08-11 - [x] Fix flake8 errors across the codebase. +- [x] Add loopback link tests for client requests and resource transfer. diff --git a/reticulum_openapi/link_service.py b/reticulum_openapi/link_service.py index 3938acf..392a50b 100644 --- a/reticulum_openapi/link_service.py +++ b/reticulum_openapi/link_service.py @@ -2,6 +2,7 @@ import asyncio import os +import shutil from typing import Callable @@ -11,7 +12,6 @@ class LinkResourceService: - """Service utilities for receiving resources on a link.""" def __init__( @@ -138,5 +138,3 @@ async def stop(self) -> None: for task in self._keepalive_tasks.values(): task.cancel() self._keepalive_tasks.clear() - - \ No newline at end of file diff --git a/tests/test_link_client.py b/tests/test_link_client.py index 3a73a5a..3b76b36 100644 --- a/tests/test_link_client.py +++ b/tests/test_link_client.py @@ -1,9 +1,11 @@ import asyncio +import os from types import SimpleNamespace import pytest from reticulum_openapi import link_client as lc_module +from reticulum_openapi import link_service as ls_module class FakeLink: @@ -80,7 +82,6 @@ async def test_send_serializes_dict(monkeypatch): lambda d: (captured.setdefault("payload", d), b"data")[1], ) - cli = lc_module.LinkClient("aa") await cli.send({"k": "v"}) assert captured["payload"] == {"k": "v"} @@ -115,3 +116,186 @@ async def test_identify_calls_link(monkeypatch): ident = FakeIdentity() cli.identify(ident) assert cli.link.ident_called_with is ident + + +# --------------------------------------------------------------------------- +# Loopback link helpers for integration-style tests +# --------------------------------------------------------------------------- + + +class LoopbackIdentity: + registry = {} + + def __init__(self, hash_bytes: bytes | None = None): + self.hash = hash_bytes or os.urandom(8) + LoopbackIdentity.registry[self.hash] = self + + @staticmethod + def recall(hash_bytes: bytes, create: bool = False): + if hash_bytes in LoopbackIdentity.registry: + return LoopbackIdentity.registry[hash_bytes] + if create: + return LoopbackIdentity(hash_bytes) + return None + + +class LoopbackDestination: + OUT = object() + IN = object() + SINGLE = object() + + def __init__(self, identity, *_): + self.identity = identity + self.accepts_links = False + self.link_established_callback = None + + def set_link_established_callback(self, cb): + self.link_established_callback = cb + NETWORK[self.identity.hash] = cb + + +NETWORK = {} + + +class LoopbackLink: + def __init__( + self, dest, established_callback=None, closed_callback=None, peer=None + ): + self.packet_callback = None + self.request_handler = None + self.resource_callback = None + self._closed_cb = closed_callback + self.link_id = os.urandom(2) + if peer is None: + peer = LoopbackLink(dest, peer=self) + self.peer = peer + if established_callback: + established_callback(self) + cb = NETWORK.get(dest.identity.hash) + if cb: + cb(peer) + else: + self.peer = peer + + def set_packet_callback(self, cb): + self.packet_callback = cb + + def send(self, data): + if self.peer.packet_callback: + self.peer.packet_callback(data) + + def request( + self, + path, + data=None, + response_callback=None, + failed_callback=None, + timeout=None, + ): + if self.peer.request_handler: + + def responder(payload: bytes): + if response_callback: + response_callback(SimpleNamespace(response=payload)) + + self.peer.request_handler(path, data, responder) + elif failed_callback: + failed_callback(SimpleNamespace()) + return SimpleNamespace() + + def set_link_closed_callback(self, cb): + self._closed_cb = cb + + def close(self): + if self._closed_cb: + self._closed_cb(self) + if self.peer._closed_cb: + self.peer._closed_cb(self.peer) + + def send_keepalive(self): # pragma: no cover - not needed for tests + pass + + +class DummyReticulum: + def __init__(self, *a, **k): + pass + + +class FakeResource: + def __init__( + self, path, link, metadata=None, callback=None, progress_callback=None + ): + if link.peer and link.peer.resource_callback: + res = SimpleNamespace(metadata=metadata, storagepath=path, hash=b"h1") + link.peer.resource_callback(res) + if progress_callback: + progress_callback(self) + if callback: + callback(self) + + +@pytest.mark.asyncio +async def test_loopback_request_receives_response(monkeypatch): + """Ensure requests over a loopback link yield expected responses.""" + NETWORK.clear() + for module in (lc_module, ls_module): + monkeypatch.setattr(module.RNS, "Reticulum", DummyReticulum) + monkeypatch.setattr(module.RNS, "Identity", LoopbackIdentity) + monkeypatch.setattr(module.RNS, "Destination", LoopbackDestination) + monkeypatch.setattr(module.RNS, "Link", LoopbackLink) + + handler_called = asyncio.Event() + + async def handler(link): + handler_called.set() + + def handle_req(path, data, respond): + respond(b"pong") + + link.request_handler = handle_req + + service = ls_module.LinkService(link_handler=handler) + client = lc_module.LinkClient(service.identity.hash.hex()) + await asyncio.wait_for(client.established.wait(), 1) + await asyncio.wait_for(handler_called.wait(), 1) + + response = await client.request("/path") + assert response == b"pong" + + +@pytest.mark.asyncio +async def test_loopback_send_resource(monkeypatch, tmp_path): + """Resources should be transferred to the service storage directory.""" + NETWORK.clear() + for module in (lc_module, ls_module): + monkeypatch.setattr(module.RNS, "Reticulum", DummyReticulum) + monkeypatch.setattr(module.RNS, "Identity", LoopbackIdentity) + monkeypatch.setattr(module.RNS, "Destination", LoopbackDestination) + monkeypatch.setattr(module.RNS, "Link", LoopbackLink) + monkeypatch.setattr(lc_module.RNS, "Resource", FakeResource) + + storage = tmp_path / "store" + resource_service = ls_module.LinkResourceService(str(storage)) + + handler_ready = asyncio.Event() + + async def handler(link): + link.resource_callback = resource_service.resource_received_callback + handler_ready.set() + + service = ls_module.LinkService(link_handler=handler) + client = lc_module.LinkClient(service.identity.hash.hex()) + await asyncio.wait_for(client.established.wait(), 1) + await asyncio.wait_for(handler_ready.wait(), 1) + + file_path = tmp_path / "data.txt" + file_path.write_text("payload") + upload_done = asyncio.Event() + fc = lc_module.LinkFileClient( + client.link, on_upload_complete=lambda _r: upload_done.set() + ) + fc.send_resource(str(file_path)) + await asyncio.wait_for(upload_done.wait(), 1) + + stored = storage / "data.txt" + assert stored.read_text() == "payload" diff --git a/tests/test_link_resources.py b/tests/test_link_resources.py index 0d3f1ce..40cc36f 100644 --- a/tests/test_link_resources.py +++ b/tests/test_link_resources.py @@ -42,7 +42,6 @@ def completion(res): def hook(res): calls["hook"] = True - cli = link_client.LinkFileClient(fake_link, on_upload_complete=hook) cli.send_resource( @@ -62,6 +61,7 @@ def test_send_resource_raises(monkeypatch, tmp_path): def raise_resource(*a, **k): raise ValueError("boom") + monkeypatch.setattr(link_client.RNS, "Resource", raise_resource) cli = link_client.LinkFileClient(object()) @@ -75,7 +75,6 @@ def test_resource_received_callback(tmp_path): service = link_service.LinkResourceService(str(storage)) - src_path = tmp_path / "incoming" src_path.write_bytes(b"content") res = SimpleNamespace( @@ -96,10 +95,8 @@ def test_resource_received_callback_no_metadata(tmp_path): def hook(path): called["path"] = path - service = link_service.LinkResourceService(str(storage), on_download_complete=hook) - src_path = tmp_path / "incoming" src_path.write_bytes(b"data") res = SimpleNamespace(metadata=None, storagepath=str(src_path), hash=b"\x0a\x0b") diff --git a/tests/test_link_service.py b/tests/test_link_service.py index 0284204..f8a789e 100644 --- a/tests/test_link_service.py +++ b/tests/test_link_service.py @@ -1,9 +1,11 @@ import asyncio +import os from types import SimpleNamespace import pytest from reticulum_openapi import link_service as ls_module +from reticulum_openapi import link_client as lc_module class FakeLink: @@ -76,3 +78,127 @@ async def test_service_stop_closes_links(monkeypatch): await service.stop() assert link.closed assert service.active_links == {} + + +# --------------------------------------------------------------------------- +# Loopback link helpers and integration test +# --------------------------------------------------------------------------- + + +class LoopbackIdentity: + registry = {} + + def __init__(self, hash_bytes: bytes | None = None): + self.hash = hash_bytes or os.urandom(8) + LoopbackIdentity.registry[self.hash] = self + + @staticmethod + def recall(hash_bytes: bytes, create: bool = False): + if hash_bytes in LoopbackIdentity.registry: + return LoopbackIdentity.registry[hash_bytes] + if create: + return LoopbackIdentity(hash_bytes) + return None + + +class LoopbackDestination: + IN = object() + OUT = object() + SINGLE = object() + + def __init__(self, identity, *_): + self.identity = identity + self.accepts_links = False + self.link_established_callback = None + + def set_link_established_callback(self, cb): + self.link_established_callback = cb + NETWORK[self.identity.hash] = cb + + +NETWORK = {} + + +class LoopbackLink: + def __init__( + self, dest, established_callback=None, closed_callback=None, peer=None + ): + self.packet_callback = None + self.request_handler = None + self.resource_callback = None + self._closed_cb = closed_callback + self.link_id = os.urandom(2) + if peer is None: + peer = LoopbackLink(dest, peer=self) + self.peer = peer + if established_callback: + established_callback(self) + cb = NETWORK.get(dest.identity.hash) + if cb: + cb(peer) + else: + self.peer = peer + + def set_packet_callback(self, cb): + self.packet_callback = cb + + def send(self, data): + if self.peer.packet_callback: + self.peer.packet_callback(data) + + def request( + self, + path, + data=None, + response_callback=None, + failed_callback=None, + timeout=None, + ): + if self.peer.request_handler: + + def responder(payload: bytes): + if response_callback: + response_callback(SimpleNamespace(response=payload)) + + self.peer.request_handler(path, data, responder) + elif failed_callback: + failed_callback(SimpleNamespace()) + return SimpleNamespace() + + def set_link_closed_callback(self, cb): + self._closed_cb = cb + + def close(self): + if self._closed_cb: + self._closed_cb(self) + if self.peer._closed_cb: + self.peer._closed_cb(self.peer) + + def send_keepalive(self): # pragma: no cover - not used + pass + + +class DummyReticulum: + def __init__(self, *a, **k): + pass + + +@pytest.mark.asyncio +async def test_loopback_link_established(monkeypatch): + """Client and service should both receive establishment callbacks.""" + NETWORK.clear() + for module in (ls_module, lc_module): + monkeypatch.setattr(module.RNS, "Reticulum", DummyReticulum) + monkeypatch.setattr(module.RNS, "Identity", LoopbackIdentity) + monkeypatch.setattr(module.RNS, "Destination", LoopbackDestination) + monkeypatch.setattr(module.RNS, "Link", LoopbackLink) + + handler_called = asyncio.Event() + + async def handler(_link): + handler_called.set() + + service = ls_module.LinkService(link_handler=handler) + client = lc_module.LinkClient(service.identity.hash.hex()) + await asyncio.wait_for(client.established.wait(), 1) + await asyncio.wait_for(handler_called.wait(), 1)