Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@

## 2025-08-11
- [x] Fix flake8 errors across the codebase.
- [x] Add loopback link tests for client requests and resource transfer.
- [x] Update generator docs to use Python tooling and note post-generation tweaks.

185 changes: 185 additions & 0 deletions tests/test_link_client.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -114,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"
126 changes: 126 additions & 0 deletions tests/test_link_service.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)