# retoor import asyncio import time import pytest from devplacepy.database import get_table, set_consent, set_setting from devplacepy.database.nodes import ( create_device, revoke_device, save_node_settings, suspend_device, ) from devplacepy.services.nodes.service import NodesService from devplacepy.utils import register_account from tests.conftest import run_async from tests.unit.services.nodes.session import FakeSocket from devplacepy.services.nodes.session import NodeSession _counter = [0] def member(): _counter[0] += 1 name = f"svc{int(time.time() * 1000)}{_counter[0]}" uid, _, _ = register_account( name, f"{name}@t.dev", "secret123", age_band="adult", accepted_terms=True ) set_consent("user", uid, "bandwidth_sharing", True) save_node_settings(uid, {"enabled": True}) return uid def connected(service, uid): device, _ = create_device(uid, "x", "native") socket = FakeSocket() session = NodeSession(socket, device) service.registry.admit(session) return device, socket @pytest.fixture def service(local_db): set_setting("nodes_enabled", "1") yield NodesService() set_setting("nodes_enabled", "0") def reconcile(service): return run_async(service.reconcile()) def test_healthy_nodes_stay_connected(service): _, socket = connected(service, member()) assert reconcile(service) == 0 assert socket.closed is None def test_the_kill_switch_disconnects_everyone_in_one_tick(service): sockets = [connected(service, member())[1] for _ in range(4)] set_setting("nodes_enabled", "0") assert reconcile(service) == 4 assert service.registry.count() == 0 assert all(socket.sent[-1] == {"type": "kick", "reason": "kill_switch"} for socket in sockets) def test_withdrawn_consent_disconnects_that_owner_only(service): leaving, staying = member(), member() _, gone = connected(service, leaving) _, kept = connected(service, staying) set_consent("user", leaving, "bandwidth_sharing", False) assert reconcile(service) == 1 assert gone.sent[-1]["reason"] == "no_consent" assert kept.closed is None and service.registry.count() == 1 def test_a_revoked_device_is_kicked(service): uid = member() device, socket = connected(service, uid) revoke_device(device["uid"], uid) assert reconcile(service) == 1 assert socket.sent[-1]["reason"] == "revoked" def test_a_suspended_device_is_kicked(service): uid = member() device, socket = connected(service, uid) suspend_device(device["uid"], "test", 30) assert reconcile(service) == 1 assert socket.sent[-1]["reason"] == "suspended" def test_donation_turned_off_by_the_member_disconnects(service): uid = member() _, socket = connected(service, uid) save_node_settings(uid, {"enabled": False}) assert reconcile(service) == 1 assert socket.sent[-1]["reason"] == "not_enabled" def test_a_deleted_account_is_disconnected(service): uid = member() _, socket = connected(service, uid) get_table("users").update( {"uid": uid, "deletion_requested_at": "2026-01-01T00:00:00+00:00"}, ["uid"] ) assert reconcile(service) == 1 assert socket.sent[-1]["reason"] == "deletion_pending" def test_the_gate_is_asked_once_for_all_owners_per_tick(service, monkeypatch): uid, other = member(), member() for owner in (uid, uid, uid, other): connected(service, owner) calls = [] original = service.gate.allows_many def spy(uids): uids = list(uids) calls.append(sorted(set(uids))) return original(uids) monkeypatch.setattr(service.gate, "allows_many", spy) reconcile(service) assert calls == [sorted({uid, other})] def test_an_empty_registry_costs_nothing(service, monkeypatch): monkeypatch.setattr(service.gate, "allows_many", lambda uids: 1 / 0) assert reconcile(service) == 0 def test_stopping_the_service_kicks_all(service): _, socket = connected(service, member()) run_async(service.on_disable()) assert socket.sent[-1]["reason"] == "service_stopped" assert service.registry.count() == 0 def test_metrics_report_connected_and_capacity(service): connected(service, member()) labels = {item["label"]: item["value"] for item in service.collect_metrics()["stats"]} assert labels["connected"] == 1 and labels["capacity"] > 0 def test_the_service_has_the_kill_switch_as_a_config_field(service): keys = [field.key for field in service.all_fields()] assert "nodes_enabled" in keys and "nodes_max_total" in keys def test_changed_settings_are_pushed_to_the_node_once(service): uid = member() device, socket = connected(service, uid) session = service.registry.get(device["uid"]) session.settings = {"stale": True} assert reconcile(service) == 0 pushed = [frame for frame in socket.sent if frame["type"] == "settings_update"] assert len(pushed) == 1 and pushed[0]["settings"]["enabled"] is True assert session.settings == pushed[0]["settings"] reconcile(service) assert len([frame for frame in socket.sent if frame["type"] == "settings_update"]) == 1 save_node_settings(uid, {"bandwidth_mb_day": 9}) reconcile(service) assert session.settings["bandwidth_mb_day"] == 9 def test_the_dispatch_loop_starts_once_and_stops_with_the_service(service): async def scenario(): service.ensure_dispatching() first = service._dispatch_task service.ensure_dispatching() assert service._dispatch_task is first and not first.done() await service.on_disable() await asyncio.sleep(0) assert service._dispatch_task is None and first.cancelled() run_async(scenario()) def test_a_timed_suspension_tells_the_node_when_it_ends(service): uid = member() device, socket = connected(service, uid) suspend_device(device["uid"], "test", 30) reconcile(service) frame = socket.sent[-1] assert frame["reason"] == "suspended" and frame["until"].startswith("20") def test_other_kicks_carry_no_until(service): _, socket = connected(service, member()) set_setting("nodes_enabled", "0") reconcile(service) assert "until" not in socket.sent[-1] def test_persist_state_touches_flushes_and_saves_circuits(service): from devplacepy.database import nodes as store uid = member() device, _ = connected(service, uid) get_table("node_devices").update({"uid": device["uid"], "last_seen_at": "2000-01-01T00:00:00+00:00"}, ["uid"]) store.flush_counters({}) service.counters.inc("kicked") service.counters.observe("reddit", "residential", "ok") service.breaker.failure(("destination", "reddit.com")) service.persist_state(force=True) assert store.get_device(device["uid"])["last_seen_at"] > "2020" lifetime = store.lifetime_counters() assert lifetime["kicked"] >= 1 and lifetime["outcome:reddit:residential:ok"] >= 1 assert service.counters.take_pending() == {} assert not service.breaker.dirty def test_a_failed_flush_keeps_the_deltas(service, monkeypatch): from devplacepy.services.nodes import service as module service.counters.inc("kicked") def boom(deltas): raise RuntimeError("disk full") monkeypatch.setattr(module, "flush_counters", boom) with pytest.raises(RuntimeError): service.persist_state(force=True) assert service.counters.take_pending() == {"kicked": 1} def test_persist_state_respects_the_intervals(service, monkeypatch): from devplacepy.services.nodes import service as module connected(service, member()) calls = [] monkeypatch.setattr(module, "touch_devices", lambda uids: calls.append(list(uids))) service.persist_state(force=True) service.persist_state() assert len(calls) == 1 def test_open_circuits_survive_a_restart(service): from devplacepy.database.nodes import load_circuits for _ in range(service.breaker.failures): service.breaker.failure(("destination", "reddit.com")) service.persist_state() assert [row["key"] for row in load_circuits()] == ["destination:reddit.com"] fresh = NodesService() run_async(fresh.on_enable()) assert fresh.breaker.open_count() == 1 fresh.breaker.success(("destination", "reddit.com")) fresh.persist_state() assert load_circuits() == [] run_async(fresh.on_disable())