mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-28 04:23:22 +02:00
0ed92013f9
Updated the _persist_companion_message method to accept a queue_entry parameter for more precise removal of messages from the bridge queue. Introduced a new _remove_queue_entry method to ensure messages are removed by identity, preventing potential message loss during concurrent operations. Adjusted related tests to reflect these changes and verify correct behavior.
752 lines
29 KiB
Python
752 lines
29 KiB
Python
"""Tests for per-companion bridge settings parsing and startup guard."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from openhop_core.companion import CompanionBridge
|
|
from openhop_core.protocol import LocalIdentity
|
|
|
|
from repeater.companion.utils import (
|
|
COMPANION_SETTINGS_ALLOWLIST,
|
|
CompanionContactCapacityError,
|
|
check_companion_contact_capacity,
|
|
effective_max_contacts,
|
|
enforce_companion_contact_capacity,
|
|
merge_companion_settings_update,
|
|
parse_companion_bridge_kwargs,
|
|
parse_positive_int,
|
|
select_companion_contacts_to_trim,
|
|
trim_companion_contacts_to_fit,
|
|
validate_companion_config_capacity,
|
|
)
|
|
from repeater.main import RepeaterDaemon
|
|
|
|
# openhop_core defaults (CompanionBridge / ContactStore)
|
|
_DEFAULT_MAX_CONTACTS = 1000
|
|
|
|
|
|
class TestParsePositiveInt:
|
|
def test_valid(self):
|
|
assert parse_positive_int("100", "max_contacts") == 100
|
|
|
|
def test_invalid_type(self):
|
|
with pytest.raises(ValueError, match="max_contacts"):
|
|
parse_positive_int("abc", "max_contacts")
|
|
|
|
def test_below_minimum(self):
|
|
with pytest.raises(ValueError, match="max_contacts"):
|
|
parse_positive_int(0, "max_contacts")
|
|
|
|
|
|
class TestParseCompanionBridgeKwargs:
|
|
def test_empty_settings(self):
|
|
assert parse_companion_bridge_kwargs({}) == {}
|
|
|
|
def test_max_contacts_and_offline_queue(self):
|
|
assert parse_companion_bridge_kwargs(
|
|
{"max_contacts": 2000, "offline_queue_size": 1024}
|
|
) == {"max_contacts": 2000, "offline_queue_size": 1024}
|
|
|
|
def test_ignored_keys_warn(self, caplog):
|
|
caplog.set_level(logging.WARNING)
|
|
result = parse_companion_bridge_kwargs(
|
|
{"max_contacts": 500, "max_channels": 64, "adv_type": 2}
|
|
)
|
|
assert result == {"max_contacts": 500}
|
|
assert any("max_channels" in r.message for r in caplog.records)
|
|
assert any("adv_type" in r.message for r in caplog.records)
|
|
|
|
def test_invalid_max_contacts(self):
|
|
with pytest.raises(ValueError):
|
|
parse_companion_bridge_kwargs({"max_contacts": -1})
|
|
|
|
|
|
class TestCompanionRadioCapabilities:
|
|
def test_reads_active_radio_state_and_known_sx1262_limit(self):
|
|
# The SX1262 driver declares its 22 dBm limit as a backend attribute
|
|
# (SX1262Radio.max_tx_power_dbm); the daemon no longer string-matches
|
|
# radio_type to recover it.
|
|
radio = SimpleNamespace(
|
|
frequency=868_000_000,
|
|
bandwidth=125_000,
|
|
spreading_factor=7,
|
|
coding_rate=8,
|
|
tx_power=14,
|
|
max_tx_power_dbm=22,
|
|
)
|
|
daemon = RepeaterDaemon.__new__(RepeaterDaemon)
|
|
daemon.config = {"radio_type": "sx1262", "radio": {"frequency": 915_000_000}}
|
|
daemon.repeater_handler = SimpleNamespace(radio_config={"frequency": 915_000_000})
|
|
daemon.radio = radio
|
|
|
|
assert RepeaterDaemon._get_companion_radio_settings(daemon) == {
|
|
"frequency": 868_000_000,
|
|
"bandwidth": 125_000,
|
|
"spreading_factor": 7,
|
|
"coding_rate": 8,
|
|
"tx_power": 14,
|
|
}
|
|
assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 22
|
|
|
|
def test_prefers_backend_declared_maximum(self):
|
|
daemon = RepeaterDaemon.__new__(RepeaterDaemon)
|
|
daemon.config = {"radio_type": "sx1262"}
|
|
daemon.repeater_handler = SimpleNamespace(radio_config={})
|
|
daemon.radio = SimpleNamespace(max_tx_power_dbm=19)
|
|
|
|
assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 19
|
|
|
|
def test_uses_configured_limit_when_backend_cannot_declare_one(self):
|
|
daemon = RepeaterDaemon.__new__(RepeaterDaemon)
|
|
daemon.config = {"radio_type": "kiss"}
|
|
daemon.repeater_handler = SimpleNamespace(radio_config={"max_tx_power_dbm": 15})
|
|
daemon.radio = SimpleNamespace()
|
|
|
|
assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 15
|
|
|
|
def test_backend_class_attribute_reaches_self_info_max_tx_power(self):
|
|
# A driver declares its limit as a class attribute (as SX1262Radio
|
|
# does); no radio_type string match is involved.
|
|
class _FakeRadio:
|
|
max_tx_power_dbm = 20
|
|
|
|
daemon = RepeaterDaemon.__new__(RepeaterDaemon)
|
|
daemon.config = {"radio_type": "sx1262_ch341"}
|
|
daemon.repeater_handler = SimpleNamespace(radio_config={})
|
|
daemon.radio = _FakeRadio()
|
|
|
|
assert RepeaterDaemon._get_companion_max_tx_power_dbm(daemon) == 20
|
|
|
|
# The daemon getter is what a bridge is wired with at load time; the
|
|
# value must surface through the companion SELF_INFO max-tx-power path.
|
|
bridge = CompanionBridge(
|
|
LocalIdentity(),
|
|
AsyncMock(return_value=True),
|
|
max_tx_power_getter=daemon._get_companion_max_tx_power_dbm,
|
|
)
|
|
assert bridge.get_max_tx_power_dbm() == 20
|
|
|
|
|
|
class TestEffectiveMaxContacts:
|
|
def test_default(self):
|
|
assert effective_max_contacts({}) == _DEFAULT_MAX_CONTACTS
|
|
|
|
def test_override(self):
|
|
assert effective_max_contacts({"max_contacts": 500}) == 500
|
|
|
|
|
|
class TestMergeCompanionSettingsUpdate:
|
|
def test_merges_bridge_settings(self):
|
|
merged = merge_companion_settings_update(
|
|
{"node_name": "a"},
|
|
{"max_contacts": 500},
|
|
)
|
|
assert merged == {"node_name": "a", "max_contacts": 500}
|
|
|
|
def test_unknown_key_raises(self):
|
|
with pytest.raises(ValueError, match="Unknown companion setting"):
|
|
merge_companion_settings_update({}, {"max_channels": 64})
|
|
|
|
|
|
class TestValidateCompanionConfigCapacity:
|
|
def test_uses_merged_settings_not_stale_identity(self):
|
|
identity = {
|
|
"identity_key": "aa" * 32,
|
|
"settings": {"max_contacts": 1000},
|
|
}
|
|
sqlite = MagicMock()
|
|
sqlite.companion_count_contacts.return_value = 600
|
|
with pytest.raises(CompanionContactCapacityError):
|
|
validate_companion_config_capacity(
|
|
identity,
|
|
sqlite,
|
|
settings={"max_contacts": 500},
|
|
)
|
|
sqlite.companion_count_contacts.assert_called_once()
|
|
|
|
|
|
class TestCheckCompanionContactCapacity:
|
|
def test_skips_without_sqlite(self):
|
|
check_companion_contact_capacity("0x01", 100, None)
|
|
|
|
def test_passes_when_under_limit(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_count_contacts.return_value = 100
|
|
check_companion_contact_capacity("0x01", 500, sqlite)
|
|
|
|
def test_raises_when_over_limit(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_count_contacts.return_value = 812
|
|
with pytest.raises(CompanionContactCapacityError) as exc:
|
|
check_companion_contact_capacity("0xab", 500, sqlite, companion_name="BotCompanion")
|
|
assert exc.value.stored_count == 812
|
|
assert exc.value.max_contacts == 500
|
|
assert "BotCompanion" in str(exc.value)
|
|
|
|
|
|
class TestOfflineQueueOff:
|
|
def test_zero_allowed(self):
|
|
assert parse_companion_bridge_kwargs({"offline_queue_size": 0}) == {"offline_queue_size": 0}
|
|
|
|
def test_max_contacts_zero_still_rejected(self):
|
|
with pytest.raises(ValueError, match="max_contacts"):
|
|
parse_companion_bridge_kwargs({"max_contacts": 0})
|
|
|
|
|
|
class TestSelectCompanionContactsToTrim:
|
|
@staticmethod
|
|
def _c(pk, flags=0, lastmod=0):
|
|
return {"pubkey": pk, "flags": flags, "lastmod": lastmod}
|
|
|
|
def test_under_limit_keeps_all(self):
|
|
contacts = [self._c(b"\x01"), self._c(b"\x02")]
|
|
keep, removed = select_companion_contacts_to_trim(contacts, 5)
|
|
assert removed == []
|
|
assert keep == contacts
|
|
|
|
def test_evicts_oldest_non_favourite_and_protects_favourites(self):
|
|
contacts = [
|
|
self._c(b"\x01", lastmod=10),
|
|
self._c(b"\x02", lastmod=30),
|
|
self._c(b"\x03", flags=1, lastmod=5), # favourite + oldest -> protected
|
|
self._c(b"\x04", lastmod=20),
|
|
]
|
|
keep, removed = select_companion_contacts_to_trim(contacts, 2)
|
|
assert {c["pubkey"] for c in keep} == {b"\x03", b"\x02"}
|
|
assert {c["pubkey"] for c in removed} == {b"\x01", b"\x04"}
|
|
|
|
def test_refuses_when_favourites_exceed_limit(self):
|
|
contacts = [
|
|
self._c(b"\x01", flags=1, lastmod=1),
|
|
self._c(b"\x02", flags=1, lastmod=2),
|
|
]
|
|
with pytest.raises(ValueError, match="favourite"):
|
|
select_companion_contacts_to_trim(contacts, 1)
|
|
|
|
|
|
class TestSqliteRetentionTrim:
|
|
@staticmethod
|
|
def _handler(tmp_path):
|
|
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
|
|
|
|
return SQLiteHandler(tmp_path)
|
|
|
|
@staticmethod
|
|
def _push(h, companion_hash, i, max_messages=None):
|
|
return h.companion_push_message(
|
|
companion_hash,
|
|
{"text": f"m{i}", "timestamp": i, "packet_hash": f"{companion_hash}-{i}"},
|
|
max_messages=max_messages,
|
|
)
|
|
|
|
def test_trims_to_max_messages(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
results = [self._push(h, "0x01", i, max_messages=3) for i in range(5)]
|
|
assert results == [True, True, True, False, False]
|
|
assert [m["text"] for m in h.companion_load_messages("0x01")] == ["m0", "m1", "m2"]
|
|
|
|
def test_evicts_oldest_channel_message_before_direct_message(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
direct_one = {"text": "direct one", "packet_hash": "d1", "is_channel": False}
|
|
channel_one = {"text": "channel one", "packet_hash": "c1", "is_channel": True}
|
|
direct_two = {"text": "direct two", "packet_hash": "d2", "is_channel": False}
|
|
|
|
assert h.companion_push_message("0x01", direct_one, max_messages=2)
|
|
assert h.companion_push_message("0x01", channel_one, max_messages=2)
|
|
assert h.companion_push_message("0x01", direct_two, max_messages=2)
|
|
|
|
messages = h.companion_load_messages("0x01")
|
|
assert [m["text"] for m in messages] == ["direct one", "direct two"]
|
|
assert [m["is_channel"] for m in messages] == [0, 0]
|
|
|
|
def test_rejects_channel_when_queue_contains_only_direct_messages(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
for packet_hash in ("d1", "d2"):
|
|
assert h.companion_push_message(
|
|
"0x01", {"text": packet_hash, "packet_hash": packet_hash}, max_messages=2
|
|
)
|
|
|
|
assert not h.companion_push_message(
|
|
"0x01", {"text": "channel", "packet_hash": "c1", "is_channel": True}, max_messages=2
|
|
)
|
|
assert [m["text"] for m in h.companion_load_messages("0x01")] == ["d1", "d2"]
|
|
|
|
def test_rejected_insert_keeps_existing_channels_when_limit_is_lowered(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
existing = [
|
|
{"text": "direct one", "packet_hash": "d1", "is_channel": False},
|
|
{"text": "channel one", "packet_hash": "c1", "is_channel": True},
|
|
{"text": "direct two", "packet_hash": "d2", "is_channel": False},
|
|
]
|
|
for message in existing:
|
|
assert h.companion_push_message("0x01", message)
|
|
|
|
assert not h.companion_push_message(
|
|
"0x01", {"text": "incoming", "packet_hash": "d3"}, max_messages=2
|
|
)
|
|
assert [m["text"] for m in h.companion_load_messages("0x01")] == [
|
|
"direct one",
|
|
"channel one",
|
|
"direct two",
|
|
]
|
|
|
|
def test_none_keeps_all(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
for i in range(5):
|
|
self._push(h, "0x01", i, max_messages=None)
|
|
assert len(h.companion_load_messages("0x01")) == 5
|
|
|
|
def test_trim_isolated_per_companion(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
for i in range(4):
|
|
self._push(h, "0x01", i, max_messages=2)
|
|
for i in range(3):
|
|
self._push(h, "0x02", i, max_messages=None)
|
|
assert len(h.companion_load_messages("0x01")) == 2
|
|
assert len(h.companion_load_messages("0x02")) == 3
|
|
|
|
def test_evicts_insertion_oldest_when_clock_steps_backwards(self, tmp_path, monkeypatch):
|
|
from repeater.data_acquisition import sqlite_handler
|
|
|
|
h = self._handler(tmp_path)
|
|
for i in range(3):
|
|
assert h.companion_push_message(
|
|
"0x01",
|
|
{"text": f"c{i}", "packet_hash": f"c{i}", "is_channel": True},
|
|
max_messages=3,
|
|
)
|
|
|
|
# The incoming row records a created_at older than every existing row.
|
|
# Insertion-order (id) eviction must drop the oldest existing row and
|
|
# keep the new push, rather than treating the incoming row as oldest.
|
|
monkeypatch.setattr(sqlite_handler.time, "time", lambda: 1.0)
|
|
assert h.companion_push_message(
|
|
"0x01",
|
|
{"text": "c3", "packet_hash": "c3", "is_channel": True},
|
|
max_messages=3,
|
|
)
|
|
|
|
assert [m["text"] for m in h.companion_load_messages("0x01")] == ["c1", "c2", "c3"]
|
|
|
|
def test_lowered_limit_evicts_multiple_channels_in_one_push(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
seed = [
|
|
{"text": "d1", "packet_hash": "d1", "is_channel": False},
|
|
{"text": "d2", "packet_hash": "d2", "is_channel": False},
|
|
{"text": "c1", "packet_hash": "c1", "is_channel": True},
|
|
{"text": "c2", "packet_hash": "c2", "is_channel": True},
|
|
{"text": "c3", "packet_hash": "c3", "is_channel": True},
|
|
]
|
|
for message in seed:
|
|
assert h.companion_push_message("0x01", message)
|
|
|
|
assert h.companion_push_message(
|
|
"0x01",
|
|
{"text": "c4", "packet_hash": "c4", "is_channel": True},
|
|
max_messages=4,
|
|
)
|
|
|
|
messages = h.companion_load_messages("0x01")
|
|
assert [m["text"] for m in messages] == ["d1", "d2", "c3", "c4"]
|
|
assert [m["is_channel"] for m in messages] == [0, 0, 1, 1]
|
|
|
|
|
|
class TestSenderPrefixPersistence:
|
|
"""sender_prefix (signed room-post author prefix) survives the SQLite round-trip."""
|
|
|
|
PREFIX = b"\xaa\xbb\xcc\xdd"
|
|
|
|
@staticmethod
|
|
def _handler(tmp_path):
|
|
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
|
|
|
|
return SQLiteHandler(tmp_path)
|
|
|
|
def _push(self, h, sender_prefix=PREFIX):
|
|
return h.companion_push_message(
|
|
"0x01",
|
|
{
|
|
"sender_key": b"\x01" * 32,
|
|
"txt_type": 2,
|
|
"timestamp": 42,
|
|
"text": "signed post",
|
|
"sender_prefix": sender_prefix,
|
|
"packet_hash": "ph-1",
|
|
},
|
|
)
|
|
|
|
def test_push_pop_round_trip(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
assert self._push(h)
|
|
msg = h.companion_pop_message("0x01")
|
|
assert msg["sender_prefix"] == self.PREFIX
|
|
assert msg["text"] == "signed post"
|
|
|
|
def test_load_messages_returns_prefix_bytes(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
assert self._push(h)
|
|
msgs = h.companion_load_messages("0x01")
|
|
assert len(msgs) == 1
|
|
assert msgs[0]["sender_prefix"] == self.PREFIX
|
|
|
|
def test_missing_prefix_defaults_empty(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
assert h.companion_push_message(
|
|
"0x01", {"text": "plain", "timestamp": 1, "packet_hash": "ph-2"}
|
|
)
|
|
msg = h.companion_pop_message("0x01")
|
|
assert msg["sender_prefix"] == b""
|
|
|
|
def test_migration_adds_column_to_existing_db(self, tmp_path):
|
|
import sqlite3
|
|
|
|
# Build a current DB, then rewind companion_messages to the
|
|
# pre-sender_prefix schema and drop the migration marker.
|
|
h = self._handler(tmp_path)
|
|
conn = sqlite3.connect(str(h.sqlite_path))
|
|
conn.execute(
|
|
"DELETE FROM migrations "
|
|
"WHERE migration_name IN ("
|
|
"'add_sender_prefix_to_companion_messages', "
|
|
"'add_signal_and_channel_data_to_companion_messages')"
|
|
)
|
|
conn.execute("ALTER TABLE companion_messages RENAME TO companion_messages_old")
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE companion_messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
companion_hash TEXT NOT NULL,
|
|
sender_key BLOB NOT NULL,
|
|
txt_type INTEGER NOT NULL DEFAULT 0,
|
|
timestamp INTEGER NOT NULL DEFAULT 0,
|
|
text TEXT NOT NULL,
|
|
is_channel INTEGER NOT NULL DEFAULT 0,
|
|
channel_idx INTEGER NOT NULL DEFAULT 0,
|
|
path_len INTEGER NOT NULL DEFAULT 0,
|
|
packet_hash TEXT,
|
|
created_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
conn.execute("DROP TABLE companion_messages_old")
|
|
conn.execute(
|
|
"INSERT INTO companion_messages "
|
|
"(companion_hash, sender_key, text, created_at) VALUES ('0x01', X'01', 'old', 1.0)"
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
h2 = self._handler(tmp_path) # re-runs migrations
|
|
# Pre-migration row decodes with an empty prefix.
|
|
old = h2.companion_pop_message("0x01")
|
|
assert old["sender_prefix"] == b""
|
|
# New rows round-trip through the migrated column.
|
|
assert self._push(h2)
|
|
assert h2.companion_pop_message("0x01")["sender_prefix"] == self.PREFIX
|
|
|
|
def test_sync_next_from_persistence_rebuilds_prefix(self):
|
|
from repeater.companion.frame_server import CompanionFrameServer
|
|
|
|
fs = CompanionFrameServer.__new__(CompanionFrameServer)
|
|
fs.sqlite_handler = MagicMock()
|
|
fs.companion_hash = "0x01"
|
|
fs.sqlite_handler.companion_pop_message.return_value = {
|
|
"sender_key": b"\x01" * 32,
|
|
"txt_type": 2,
|
|
"timestamp": 42,
|
|
"text": "signed post",
|
|
"is_channel": 0,
|
|
"channel_idx": 0,
|
|
"path_len": 0,
|
|
"sender_prefix": self.PREFIX,
|
|
}
|
|
msg = fs._sync_next_from_persistence()
|
|
assert msg.sender_prefix == self.PREFIX
|
|
|
|
|
|
class TestTrimContactsOnOverflowPolicy:
|
|
@staticmethod
|
|
def _contacts(n, favourites=0):
|
|
out = []
|
|
for i in range(n):
|
|
flags = 1 if i < favourites else 0
|
|
out.append({"pubkey": i.to_bytes(2, "big"), "flags": flags, "lastmod": i})
|
|
return out
|
|
|
|
def test_allowlist_includes_policy_key(self):
|
|
assert "trim_contacts_on_overflow" in COMPANION_SETTINGS_ALLOWLIST
|
|
# And it is accepted by the settings merge.
|
|
merged = merge_companion_settings_update({}, {"trim_contacts_on_overflow": True})
|
|
assert merged == {"trim_contacts_on_overflow": True}
|
|
|
|
def test_trim_helper_persists_kept_set(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_load_contacts.return_value = self._contacts(5)
|
|
sqlite.companion_save_contacts.return_value = True
|
|
removed = trim_companion_contacts_to_fit(sqlite, "0x01", 3)
|
|
assert removed == 2
|
|
saved_hash, saved_contacts = sqlite.companion_save_contacts.call_args[0]
|
|
assert saved_hash == "0x01"
|
|
assert len(saved_contacts) == 3
|
|
|
|
def test_trim_helper_noop_when_under_limit(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_load_contacts.return_value = self._contacts(2)
|
|
assert trim_companion_contacts_to_fit(sqlite, "0x01", 5) == 0
|
|
sqlite.companion_save_contacts.assert_not_called()
|
|
|
|
def test_enforce_guards_by_default(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_count_contacts.return_value = 600
|
|
with pytest.raises(CompanionContactCapacityError):
|
|
enforce_companion_contact_capacity("0x01", 500, sqlite)
|
|
sqlite.companion_save_contacts.assert_not_called()
|
|
|
|
def test_enforce_trims_when_policy_enabled(self):
|
|
sqlite = MagicMock()
|
|
sqlite.companion_load_contacts.return_value = self._contacts(600)
|
|
sqlite.companion_save_contacts.return_value = True
|
|
removed = enforce_companion_contact_capacity("0x01", 500, sqlite, trim=True)
|
|
assert removed == 100
|
|
|
|
|
|
class TestPersistSkipWhenOff:
|
|
@staticmethod
|
|
def _frame_server(max_size):
|
|
from repeater.companion.frame_server import CompanionFrameServer
|
|
|
|
fs = CompanionFrameServer.__new__(CompanionFrameServer)
|
|
fs.sqlite_handler = MagicMock()
|
|
fs.companion_hash = "0x01"
|
|
bridge = MagicMock()
|
|
bridge.message_queue.max_size = max_size
|
|
fs.bridge = bridge
|
|
return fs
|
|
|
|
def test_skips_persistence_when_retention_zero(self):
|
|
import asyncio
|
|
|
|
entry = object()
|
|
fs = self._frame_server(0)
|
|
asyncio.run(fs._persist_companion_message({"text": "x"}, entry))
|
|
fs.sqlite_handler.companion_push_message.assert_not_called()
|
|
fs.bridge.message_queue.remove.assert_called_once_with(entry)
|
|
|
|
def test_persists_with_retention(self):
|
|
import asyncio
|
|
|
|
fs = self._frame_server(7)
|
|
asyncio.run(fs._persist_companion_message({"text": "x"}))
|
|
fs.sqlite_handler.companion_push_message.assert_called_once_with("0x01", {"text": "x"}, 7)
|
|
|
|
def test_keeps_memory_message_when_sqlite_rejects_it(self):
|
|
import asyncio
|
|
|
|
fs = self._frame_server(7)
|
|
fs.sqlite_handler.companion_push_message.return_value = False
|
|
asyncio.run(fs._persist_companion_message({"text": "x"}))
|
|
fs.bridge.message_queue.pop_last.assert_not_called()
|
|
|
|
|
|
class TestImportRepeaterContactsCap:
|
|
"""The import endpoint must never leave persisted contacts above max_contacts.
|
|
|
|
The bulk import writes straight to SQLite, bypassing the ContactStore cap, so the
|
|
endpoint trims favourite-aware to fit after the insert.
|
|
"""
|
|
|
|
_HASH = "0x01"
|
|
|
|
@staticmethod
|
|
def _handler(tmp_path):
|
|
from repeater.data_acquisition.sqlite_handler import SQLiteHandler
|
|
|
|
return SQLiteHandler(tmp_path)
|
|
|
|
@staticmethod
|
|
def _seed_adverts(h, n, start_ts=10_000):
|
|
"""Seed ``n`` repeater adverts with increasing last_seen (newest = highest i)."""
|
|
for i in range(n):
|
|
h.store_advert(
|
|
{
|
|
"timestamp": float(start_ts + i),
|
|
"pubkey": f"{i:064x}",
|
|
"node_name": f"adv-{i}",
|
|
"is_repeater": True,
|
|
"route_type": 1,
|
|
"contact_type": "repeater",
|
|
"latitude": 0.0,
|
|
"longitude": 0.0,
|
|
}
|
|
)
|
|
|
|
@classmethod
|
|
def _save_contacts(cls, h, contacts):
|
|
assert h.companion_save_contacts(cls._HASH, contacts)
|
|
|
|
@staticmethod
|
|
def _contact(pk_int, *, flags=0, lastmod=0):
|
|
# Pre-existing contacts use a pubkey range disjoint from seeded adverts.
|
|
return {
|
|
"pubkey": (1_000_000 + pk_int).to_bytes(8, "big"),
|
|
"name": f"pre-{pk_int}",
|
|
"adv_type": 2,
|
|
"flags": flags,
|
|
"lastmod": lastmod,
|
|
"last_advert_timestamp": lastmod,
|
|
}
|
|
|
|
@classmethod
|
|
def _endpoint(cls, handler, bridge, body):
|
|
from repeater.web.companion_endpoints import CompanionAPIEndpoints
|
|
|
|
ep = CompanionAPIEndpoints.__new__(CompanionAPIEndpoints)
|
|
ep._require_post = lambda: None
|
|
ep._get_json_body = lambda: body
|
|
ep._resolve_bridge_params = lambda b: {}
|
|
ep._get_bridge = lambda **kw: bridge
|
|
ep._get_sqlite_handler = lambda: handler
|
|
return ep
|
|
|
|
@staticmethod
|
|
def _invoke(ep):
|
|
"""Call the endpoint past the @require_auth wrapper (no auth context in tests)."""
|
|
from repeater.web.companion_endpoints import CompanionAPIEndpoints
|
|
|
|
return CompanionAPIEndpoints.import_repeater_contacts.__wrapped__(ep)
|
|
|
|
@classmethod
|
|
def _bridge(cls, max_contacts):
|
|
contacts = SimpleNamespace(max_contacts=max_contacts, loaded=None)
|
|
contacts.load_from_dicts = lambda records: setattr(contacts, "loaded", list(records))
|
|
return SimpleNamespace(_companion_hash=cls._HASH, contacts=contacts)
|
|
|
|
def test_import_over_cap_trims_to_fit(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
self._seed_adverts(h, 60)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
resp = self._invoke(ep)
|
|
|
|
assert h.companion_count_contacts(self._HASH) == 50
|
|
assert resp["data"] == {"imported": 60, "removed": 10}
|
|
assert len(bridge.contacts.loaded) == 50
|
|
|
|
def test_pre_existing_plus_import_accumulation(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
# 40 old pre-existing contacts (lastmod 0..39).
|
|
self._save_contacts(h, [self._contact(i, lastmod=i) for i in range(40)])
|
|
# 30 newer imported adverts (last_seen >= 10_000).
|
|
self._seed_adverts(h, 30)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
resp = self._invoke(ep)
|
|
|
|
assert h.companion_count_contacts(self._HASH) == 50
|
|
assert resp["data"]["imported"] == 30
|
|
# All 30 newer imports survive; oldest pre-existing are evicted.
|
|
kept = {row["pubkey"] for row in h.companion_load_contacts(self._HASH)}
|
|
for i in range(30):
|
|
assert bytes.fromhex(f"{i:064x}") in kept
|
|
|
|
def test_favourites_protected(self, tmp_path):
|
|
h = self._handler(tmp_path)
|
|
# 5 favourites that are also the oldest (lastmod 0..4).
|
|
favourites = [self._contact(i, flags=1, lastmod=i) for i in range(5)]
|
|
self._save_contacts(h, favourites)
|
|
self._seed_adverts(h, 60)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
self._invoke(ep)
|
|
|
|
assert h.companion_count_contacts(self._HASH) == 50
|
|
kept = {row["pubkey"] for row in h.companion_load_contacts(self._HASH)}
|
|
for fav in favourites:
|
|
assert fav["pubkey"] in kept
|
|
|
|
def test_favourites_exceed_cap_returns_409(self, tmp_path):
|
|
import cherrypy
|
|
|
|
h = self._handler(tmp_path)
|
|
self._save_contacts(h, [self._contact(i, flags=1, lastmod=i) for i in range(51)])
|
|
self._seed_adverts(h, 1)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
with pytest.raises(cherrypy.HTTPError) as exc_info:
|
|
self._invoke(ep)
|
|
assert exc_info.value.code == 409
|
|
|
|
def test_cap_source_is_contacts_not_default(self, tmp_path):
|
|
# A companion configured above the 1000 default must not be silently clamped.
|
|
h = self._handler(tmp_path)
|
|
captured = {}
|
|
real_import = h.companion_import_repeater_contacts
|
|
|
|
def _spy(companion_hash, **kwargs):
|
|
captured["limit"] = kwargs.get("limit")
|
|
return real_import(companion_hash, **kwargs)
|
|
|
|
h.companion_import_repeater_contacts = _spy
|
|
bridge = self._bridge(max_contacts=1200)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c", "limit": 1100})
|
|
|
|
self._invoke(ep)
|
|
|
|
# min(limit=1100, max_contacts=1200) -> 1100, proving the cap came from
|
|
# bridge.contacts.max_contacts (1200), not the old 1000 fallback.
|
|
assert captured["limit"] == 1100
|
|
|
|
def test_under_cap_import_is_noop_trim(self, tmp_path):
|
|
# Happy path: an import that fits leaves everything and trims nothing.
|
|
h = self._handler(tmp_path)
|
|
self._seed_adverts(h, 10)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
resp = self._invoke(ep)
|
|
|
|
assert h.companion_count_contacts(self._HASH) == 10
|
|
assert resp["data"] == {"imported": 10, "removed": 0}
|
|
assert len(bridge.contacts.loaded) == 10
|
|
|
|
def test_incident_scale_default_cap(self, tmp_path):
|
|
# Reproduces the reported incident: an oversized import at the real 1000
|
|
# default must end at exactly the cap, not 1062.
|
|
h = self._handler(tmp_path)
|
|
self._seed_adverts(h, 1062)
|
|
bridge = self._bridge(max_contacts=_DEFAULT_MAX_CONTACTS)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
resp = self._invoke(ep)
|
|
|
|
assert h.companion_count_contacts(self._HASH) == _DEFAULT_MAX_CONTACTS
|
|
assert resp["data"] == {"imported": 1062, "removed": 62}
|
|
assert len(bridge.contacts.loaded) == _DEFAULT_MAX_CONTACTS
|
|
|
|
def test_repeated_import_stays_within_cap(self, tmp_path):
|
|
# Repeated imports (a plausible cause of the original overflow) must never
|
|
# accumulate past the cap.
|
|
h = self._handler(tmp_path)
|
|
self._seed_adverts(h, 60)
|
|
bridge = self._bridge(max_contacts=50)
|
|
ep = self._endpoint(h, bridge, {"companion_name": "c"})
|
|
|
|
first = self._invoke(ep)
|
|
assert h.companion_count_contacts(self._HASH) == 50
|
|
assert first["data"]["removed"] == 10
|
|
|
|
# Second call re-imports the same adverts (the 10 trimmed are still in the
|
|
# adverts table) and must trim back to the cap again, not climb to 60.
|
|
second = self._invoke(ep)
|
|
assert h.companion_count_contacts(self._HASH) == 50
|
|
assert second["data"]["removed"] == 10
|