feat(tests): add comprehensive tests for memory leak fixes, name overwrite handling, and frontend display name protection; refactor auto propagation tests for clarity

This commit is contained in:
Ivan committed 2026-04-13 17:37:06 -05:00
1 parent 3d54d11164
commit 1b7051a810
5 files changed
+1411 -8

No files matched your search

+6 -6
View File
@@ -54,8 +54,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
):
mock_hops.side_effect = (
lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
)
await manager.check_and_update_propagation_node()
@@ -79,8 +79,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", side_effect=[False, True]),
):
mock_hops.side_effect = (
lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
)
await manager.check_and_update_propagation_node()
@@ -106,8 +106,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
):
mock_hops.side_effect = (
lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
mock_hops.side_effect = lambda dh: (
1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
)
await manager.check_and_update_propagation_node()
+443
View File
@@ -0,0 +1,443 @@
"""Tests for the memory-leak fixes applied to websocket_broadcast,
update_lxmf_message_state, AsyncUtils.run_async future tracking,
nomadnet link cache sweeping, and the telemetry warning cap.
"""
import asyncio
import gc
import json
import secrets
import threading
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import RNS
from meshchatx.src.backend.async_utils import AsyncUtils
from meshchatx.src.backend.database.messages import MessageDAO
from meshchatx.src.backend.nomadnet_downloader import (
_nomadnet_links_lock,
nomadnet_cached_links,
sweep_stale_links,
)
# ---------------------------------------------------------------------------
# MessageDAO.update_lxmf_message_state
# ---------------------------------------------------------------------------
class TestUpdateLxmfMessageState:
@pytest.fixture
def dao(self):
provider = MagicMock()
return MessageDAO(provider), provider
def test_update_issues_correct_sql(self, dao):
message_dao, provider = dao
message_dao.update_lxmf_message_state(
message_hash="abc123",
state="delivered",
progress=100.0,
delivery_attempts=3,
next_delivery_attempt_at=None,
rssi=-55,
snr=6.5,
quality=4,
)
provider.execute.assert_called_once()
query, params = provider.execute.call_args[0]
assert "UPDATE lxmf_messages SET" in query
assert "WHERE hash = ?" in query
assert params[-1] == "abc123"
assert params[0] == "delivered"
assert params[1] == 100.0
assert params[2] == 3
def test_update_with_none_optionals(self, dao):
message_dao, provider = dao
message_dao.update_lxmf_message_state(
message_hash="xyz",
state="sending",
progress=50.0,
delivery_attempts=1,
next_delivery_attempt_at=None,
)
_, params = provider.execute.call_args[0]
assert params[4] is None # rssi
assert params[5] is None # snr
assert params[6] is None # quality
def test_update_does_not_touch_fields_column(self, dao):
"""The lightweight path must never rewrite the fields/content columns."""
message_dao, provider = dao
message_dao.update_lxmf_message_state(
message_hash="h",
state="sent",
progress=99.0,
delivery_attempts=2,
next_delivery_attempt_at=None,
)
query = provider.execute.call_args[0][0]
assert "fields" not in query.lower().split("set")[1].split("where")[0]
assert "content" not in query.lower().split("set")[1].split("where")[0]
def test_full_roundtrip_via_real_db(self, tmp_path):
"""Insert with upsert, then update state only, verify fields unchanged."""
from meshchatx.src.backend.database import Database
db_path = str(tmp_path / "test.db")
db = Database(db_path)
db.initialize()
big_fields = json.dumps({"image": {"image_bytes": "A" * 5000}})
msg = {
"hash": "deadbeef",
"source_hash": "src",
"destination_hash": "dst",
"peer_hash": "src",
"state": "outbound",
"progress": 0.0,
"is_incoming": 0,
"method": "direct",
"delivery_attempts": 0,
"next_delivery_attempt_at": None,
"title": "hi",
"content": "hello",
"fields": big_fields,
"timestamp": 12345,
"rssi": None,
"snr": None,
"quality": None,
"is_spam": 0,
"reply_to_hash": None,
"attachments_stripped": 0,
}
db.messages.upsert_lxmf_message(msg)
db.messages.update_lxmf_message_state(
message_hash="deadbeef",
state="delivered",
progress=100.0,
delivery_attempts=2,
next_delivery_attempt_at=None,
rssi=-60,
snr=7.0,
quality=5,
)
row = db.messages.get_lxmf_message_by_hash("deadbeef")
assert row["state"] == "delivered"
assert row["progress"] == 100.0
assert row["delivery_attempts"] == 2
assert row["rssi"] == -60
stored_fields = json.loads(row["fields"])
assert stored_fields["image"]["image_bytes"] == "A" * 5000
db.close_all()
# ---------------------------------------------------------------------------
# sweep_stale_links
# ---------------------------------------------------------------------------
class TestSweepStaleLinks:
@pytest.fixture(autouse=True)
def _clear_cache(self):
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
yield
with _nomadnet_links_lock:
nomadnet_cached_links.clear()
def test_sweep_removes_inactive_links(self):
active = MagicMock()
active.status = RNS.Link.ACTIVE
dead = MagicMock()
dead.status = RNS.Link.CLOSED
with _nomadnet_links_lock:
nomadnet_cached_links[b"a"] = active
nomadnet_cached_links[b"b"] = dead
sweep_stale_links()
with _nomadnet_links_lock:
assert b"a" in nomadnet_cached_links
assert b"b" not in nomadnet_cached_links
def test_sweep_empty_cache_is_noop(self):
sweep_stale_links()
with _nomadnet_links_lock:
assert len(nomadnet_cached_links) == 0
def test_sweep_all_active_keeps_all(self):
for i in range(5):
link = MagicMock()
link.status = RNS.Link.ACTIVE
with _nomadnet_links_lock:
nomadnet_cached_links[bytes([i])] = link
sweep_stale_links()
with _nomadnet_links_lock:
assert len(nomadnet_cached_links) == 5
def test_sweep_thread_safety(self):
for i in range(20):
link = MagicMock()
link.status = RNS.Link.CLOSED if i % 2 else RNS.Link.ACTIVE
with _nomadnet_links_lock:
nomadnet_cached_links[bytes([i])] = link
errors = []
def worker():
try:
sweep_stale_links()
except Exception as e:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors
with _nomadnet_links_lock:
assert len(nomadnet_cached_links) == 10
# ---------------------------------------------------------------------------
# AsyncUtils.run_async future tracking
# ---------------------------------------------------------------------------
class TestAsyncUtilsFutureTracking:
"""Tests operate directly on the real AsyncUtils class internals,
bypassing the conftest global mock for the tracking logic.
"""
@pytest.fixture(autouse=True)
def _reset(self):
AsyncUtils._pending_futures = []
yield
AsyncUtils._pending_futures = []
def test_future_appended_on_schedule(self):
mock_loop = MagicMock()
mock_loop.is_running.return_value = True
future = MagicMock()
future.done.return_value = False
real_main_loop = AsyncUtils.main_loop
real_run_async = AsyncUtils.run_async.__func__
try:
AsyncUtils.main_loop = mock_loop
with patch(
"meshchatx.src.backend.async_utils.asyncio.run_coroutine_threadsafe",
return_value=future,
):
coro = asyncio.coroutine(lambda: None)()
real_run_async(coro)
finally:
AsyncUtils.main_loop = real_main_loop
assert future in AsyncUtils._pending_futures
def test_completed_futures_swept_at_threshold(self):
done = MagicMock()
done.done.return_value = True
pending = MagicMock()
pending.done.return_value = False
AsyncUtils._pending_futures = (
[done] * (AsyncUtils._FUTURES_SWEEP_THRESHOLD - 2) + [pending]
)
mock_loop = MagicMock()
mock_loop.is_running.return_value = True
new_future = MagicMock()
new_future.done.return_value = False
real_main_loop = AsyncUtils.main_loop
real_run_async = AsyncUtils.run_async.__func__
try:
AsyncUtils.main_loop = mock_loop
with patch(
"meshchatx.src.backend.async_utils.asyncio.run_coroutine_threadsafe",
return_value=new_future,
):
coro = asyncio.coroutine(lambda: None)()
real_run_async(coro)
finally:
AsyncUtils.main_loop = real_main_loop
remaining = AsyncUtils._pending_futures
assert done not in remaining
assert pending in remaining
def test_no_loop_prints_warning(self, capsys):
real_main_loop = AsyncUtils.main_loop
real_run_async = AsyncUtils.run_async.__func__
try:
AsyncUtils.main_loop = None
coro = asyncio.coroutine(lambda: None)()
real_run_async(coro)
finally:
AsyncUtils.main_loop = real_main_loop
captured = capsys.readouterr()
assert "WARNING" in captured.out
# ---------------------------------------------------------------------------
# websocket_broadcast dead-client pruning
# ---------------------------------------------------------------------------
class TestWebsocketBroadcastPruning:
@pytest.mark.asyncio
async def test_dead_clients_removed_on_send_failure(self):
from meshchatx.meshchat import ReticulumMeshChat
good_ws = AsyncMock()
bad_ws = AsyncMock()
bad_ws.send_str.side_effect = ConnectionResetError("gone")
app = MagicMock(spec=ReticulumMeshChat)
app.websocket_clients = [good_ws, bad_ws]
await ReticulumMeshChat.websocket_broadcast(app, '{"type":"ping"}')
good_ws.send_str.assert_called_once_with('{"type":"ping"}')
bad_ws.send_str.assert_called_once()
assert bad_ws not in app.websocket_clients
assert good_ws in app.websocket_clients
@pytest.mark.asyncio
async def test_all_healthy_clients_kept(self):
from meshchatx.meshchat import ReticulumMeshChat
clients = [AsyncMock() for _ in range(3)]
app = MagicMock(spec=ReticulumMeshChat)
app.websocket_clients = list(clients)
await ReticulumMeshChat.websocket_broadcast(app, '{"ok":true}')
assert len(app.websocket_clients) == 3
for c in clients:
c.send_str.assert_called_once()
@pytest.mark.asyncio
async def test_all_dead_clients_cleared(self):
from meshchatx.meshchat import ReticulumMeshChat
clients = [AsyncMock() for _ in range(3)]
for c in clients:
c.send_str.side_effect = BrokenPipeError
app = MagicMock(spec=ReticulumMeshChat)
app.websocket_clients = list(clients)
await ReticulumMeshChat.websocket_broadcast(app, "data")
assert len(app.websocket_clients) == 0
# ---------------------------------------------------------------------------
# _telemetry_no_location_warned cap
# ---------------------------------------------------------------------------
class TestTelemetryWarnedCap:
def test_cap_at_256_then_cleared(self):
warned = set()
for i in range(256):
if len(warned) >= 256:
warned.clear()
warned.add(f"hash_{i}")
assert len(warned) == 256
if len(warned) >= 256:
warned.clear()
warned.add("hash_256")
assert len(warned) == 1
assert "hash_256" in warned
# ---------------------------------------------------------------------------
# Integration: state updates don't bloat memory with attachment re-encoding
# ---------------------------------------------------------------------------
class TestStateUpdateMemory:
def test_state_update_avoids_large_fields_write(self, tmp_path):
"""Verify that update_lxmf_message_state does NOT cause the provider
to receive the large fields blob that upsert_lxmf_message does."""
from meshchatx.src.backend.database import Database
db = Database(str(tmp_path / "t.db"))
db.initialize()
big_attachment = "X" * 100_000
fields = json.dumps({"image": {"image_bytes": big_attachment}})
msg = {
"hash": "msg1",
"source_hash": "s",
"destination_hash": "d",
"peer_hash": "s",
"state": "outbound",
"progress": 0.0,
"is_incoming": 0,
"method": "direct",
"delivery_attempts": 0,
"next_delivery_attempt_at": None,
"title": "",
"content": "",
"fields": fields,
"timestamp": 1,
"rssi": None,
"snr": None,
"quality": None,
"is_spam": 0,
"reply_to_hash": None,
"attachments_stripped": 0,
}
db.messages.upsert_lxmf_message(msg)
gc.collect()
import psutil, os
mem_before = psutil.Process(os.getpid()).memory_info().rss
for _ in range(50):
db.messages.update_lxmf_message_state(
message_hash="msg1",
state="sending",
progress=50.0,
delivery_attempts=1,
next_delivery_attempt_at=None,
)
gc.collect()
mem_after = psutil.Process(os.getpid()).memory_info().rss
delta_mb = (mem_after - mem_before) / (1024 * 1024)
assert delta_mb < 5.0, (
f"State-only updates grew memory by {delta_mb:.1f} MB; "
"they should not re-serialize attachment data"
)
row = db.messages.get_lxmf_message_by_hash("msg1")
assert row["state"] == "sending"
stored = json.loads(row["fields"])
assert stored["image"]["image_bytes"] == big_attachment
db.close_all()
+481
View File
@@ -0,0 +1,481 @@
"""Tests for the contact/conversation name overwrite bug fixes.
Covers:
- Announce upsert preserving app_data via COALESCE when new value is NULL
- Contacts DAO upsert conflict behaviour
- Custom display name lifecycle (set, get, delete, re-set)
- Contact name update propagation
- Edge cases: empty strings, unicode, very long names, concurrent upserts
"""
import base64
import os
import tempfile
import pytest
from meshchatx.src.backend.database.announces import AnnounceDAO
from meshchatx.src.backend.database.contacts import ContactsDAO
from meshchatx.src.backend.database.provider import DatabaseProvider
from meshchatx.src.backend.database.schema import DatabaseSchema
from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name
try:
import RNS.vendor.umsgpack as msgpack
except ImportError:
msgpack = None
@pytest.fixture
def temp_db():
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
yield path
if os.path.exists(path):
os.remove(path)
@pytest.fixture
def provider(temp_db):
p = DatabaseProvider(temp_db)
schema = DatabaseSchema(p)
schema.initialize()
yield p
p.close()
@pytest.fixture
def announce_dao(provider):
return AnnounceDAO(provider)
@pytest.fixture
def contacts_dao(provider):
return ContactsDAO(provider)
def _make_app_data_b64(name: str) -> str:
if msgpack is None:
return base64.b64encode(name.encode()).decode()
packed = msgpack.packb([name.encode(), None, None])
return base64.b64encode(packed).decode()
def _base_announce(dest="d" * 32, app_data="some_data"):
return {
"destination_hash": dest,
"aspect": "lxmf.delivery",
"identity_hash": "i" * 32,
"identity_public_key": "k" * 32,
"app_data": app_data,
"rssi": -60,
"snr": 8,
"quality": 0.9,
}
# ---------------------------------------------------------------------------
# Announce upsert COALESCE tests
# ---------------------------------------------------------------------------
class TestAnnounceUpsertPreservesAppData:
"""The core bug: upserting an announce with app_data=None must NOT wipe
the previously stored app_data."""
def test_null_app_data_preserves_existing(self, announce_dao):
announce_dao.upsert_announce(_base_announce(app_data="original_name"))
row = announce_dao.get_announce_by_hash("d" * 32)
assert row["app_data"] == "original_name"
announce_dao.upsert_announce(_base_announce(app_data=None))
row = announce_dao.get_announce_by_hash("d" * 32)
assert row["app_data"] == "original_name"
def test_new_app_data_overwrites_existing(self, announce_dao):
announce_dao.upsert_announce(_base_announce(app_data="old_name"))
announce_dao.upsert_announce(_base_announce(app_data="new_name"))
row = announce_dao.get_announce_by_hash("d" * 32)
assert row["app_data"] == "new_name"
def test_first_insert_with_null_app_data(self, announce_dao):
announce_dao.upsert_announce(_base_announce(app_data=None))
row = announce_dao.get_announce_by_hash("d" * 32)
assert row["app_data"] is None
def test_first_null_then_real_then_null_again(self, announce_dao):
dest = "a" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] is None
announce_dao.upsert_announce(_base_announce(dest=dest, app_data="real_name"))
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] == "real_name"
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] == "real_name"
def test_multiple_null_upserts_no_degradation(self, announce_dao):
dest = "b" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data="stable_name"))
for _ in range(10):
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] == "stable_name"
def test_other_fields_still_update_on_null_app_data(self, announce_dao):
dest = "c" * 32
announce_dao.upsert_announce(
{**_base_announce(dest=dest, app_data="keep_me"), "rssi": -50}
)
announce_dao.upsert_announce(
{**_base_announce(dest=dest, app_data=None), "rssi": -90}
)
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] == "keep_me"
assert row["rssi"] == -90
def test_empty_string_app_data_is_not_null(self, announce_dao):
dest = "e" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data="good_name"))
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=""))
row = announce_dao.get_announce_by_hash(dest)
assert row["app_data"] == ""
# ---------------------------------------------------------------------------
# Custom display name lifecycle
# ---------------------------------------------------------------------------
class TestCustomDisplayNameLifecycle:
def test_set_and_get(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "Alice")
assert announce_dao.get_custom_display_name("dest1") == "Alice"
def test_update_overwrites(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "Alice")
announce_dao.upsert_custom_display_name("dest1", "Bob")
assert announce_dao.get_custom_display_name("dest1") == "Bob"
def test_delete_removes(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "Alice")
announce_dao.delete_custom_display_name("dest1")
assert announce_dao.get_custom_display_name("dest1") is None
def test_get_nonexistent_returns_none(self, announce_dao):
assert announce_dao.get_custom_display_name("nonexistent") is None
def test_delete_nonexistent_is_noop(self, announce_dao):
announce_dao.delete_custom_display_name("nonexistent")
def test_set_after_delete(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "First")
announce_dao.delete_custom_display_name("dest1")
announce_dao.upsert_custom_display_name("dest1", "Second")
assert announce_dao.get_custom_display_name("dest1") == "Second"
def test_unicode_display_name(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "\u5c71\u7530\u592a\u90ce")
assert (
announce_dao.get_custom_display_name("dest1") == "\u5c71\u7530\u592a\u90ce"
)
def test_very_long_display_name(self, announce_dao):
long_name = "A" * 10000
announce_dao.upsert_custom_display_name("dest1", long_name)
assert announce_dao.get_custom_display_name("dest1") == long_name
def test_empty_string_display_name(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "")
assert announce_dao.get_custom_display_name("dest1") == ""
def test_multiple_destinations_independent(self, announce_dao):
announce_dao.upsert_custom_display_name("dest1", "Alice")
announce_dao.upsert_custom_display_name("dest2", "Bob")
announce_dao.delete_custom_display_name("dest1")
assert announce_dao.get_custom_display_name("dest1") is None
assert announce_dao.get_custom_display_name("dest2") == "Bob"
# ---------------------------------------------------------------------------
# Contacts DAO edge cases
# ---------------------------------------------------------------------------
class TestContactsEdgeCases:
def test_add_contact_upsert_preserves_addresses(self, contacts_dao, provider):
contacts_dao.add_contact(
"Alice", "ih1", lxmf_address="lxmf1", lxst_address="lxst1"
)
contacts_dao.add_contact(
"Alice Updated", "ih1", lxmf_address=None, lxst_address=None
)
row = provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih1",)
)
assert row["name"] == "Alice Updated"
assert row["lxmf_address"] == "lxmf1"
assert row["lxst_address"] == "lxst1"
def test_add_contact_upsert_replaces_name_unconditionally(
self, contacts_dao, provider
):
"""Verifies add_contact always overwrites name on conflict."""
contacts_dao.add_contact("Real Name", "ih2")
contacts_dao.add_contact("Overwritten", "ih2")
row = provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih2",)
)
assert row["name"] == "Overwritten"
def test_update_contact_partial(self, contacts_dao, provider):
contacts_dao.add_contact("Alice", "ih3", lxmf_address="lx3")
row = provider.fetchone(
"SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih3",)
)
cid = row["id"]
contacts_dao.update_contact(cid, name="Alice Renamed")
updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (cid,))
assert updated["name"] == "Alice Renamed"
assert updated["lxmf_address"] == "lx3"
def test_update_contact_no_fields_is_noop(self, contacts_dao, provider):
contacts_dao.add_contact("NoOp", "ih4")
row = provider.fetchone(
"SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih4",)
)
contacts_dao.update_contact(row["id"])
updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (row["id"],))
assert updated["name"] == "NoOp"
def test_update_contact_clear_image(self, contacts_dao, provider):
contacts_dao.add_contact(
"WithImage", "ih5", custom_image="data:image/png;base64,abc"
)
row = provider.fetchone(
"SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih5",)
)
assert row is not None
cid = row["id"]
contacts_dao.update_contact(cid, clear_image=True)
updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (cid,))
assert updated["custom_image"] is None
def test_get_contact_by_identity_hash_matches_lxmf_address(self, contacts_dao):
contacts_dao.add_contact("Via LXMF", "ih6", lxmf_address="lxmf6")
result = contacts_dao.get_contact_by_identity_hash("lxmf6")
assert result is not None
assert result["name"] == "Via LXMF"
def test_get_contact_by_identity_hash_matches_lxst_address(self, contacts_dao):
contacts_dao.add_contact("Via LXST", "ih7", lxst_address="lxst7")
result = contacts_dao.get_contact_by_identity_hash("lxst7")
assert result is not None
assert result["name"] == "Via LXST"
def test_delete_nonexistent_contact(self, contacts_dao):
contacts_dao.delete_contact(99999)
def test_unicode_contact_name(self, contacts_dao, provider):
contacts_dao.add_contact("\u00c9milie \u00d6sterreich", "ih8")
row = provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih8",)
)
assert row["name"] == "\u00c9milie \u00d6sterreich"
def test_contacts_search_case_insensitive_name(self, contacts_dao):
contacts_dao.add_contact("Charlie Delta", "ih9")
results = contacts_dao.get_contacts(search="charlie")
assert len(results) >= 1
assert any(r["name"] == "Charlie Delta" for r in results)
def test_contacts_count_with_search(self, contacts_dao):
contacts_dao.add_contact("Alpha", "ih10")
contacts_dao.add_contact("Beta", "ih11")
contacts_dao.add_contact("AlphaTwo", "ih12")
assert contacts_dao.get_contacts_count(search="alpha") == 2
assert contacts_dao.get_contacts_count() == 3
# ---------------------------------------------------------------------------
# parse_lxmf_display_name fallback behaviour
# ---------------------------------------------------------------------------
class TestParseLxmfDisplayNameFallback:
def test_none_returns_anonymous_peer(self):
assert parse_lxmf_display_name(None) == "Anonymous Peer"
def test_none_with_custom_default(self):
assert parse_lxmf_display_name(None, default_value="Fallback") == "Fallback"
def test_none_with_none_default(self):
assert parse_lxmf_display_name(None, default_value=None) is None
@pytest.mark.skipif(msgpack is None, reason="msgpack not available")
def test_valid_app_data_returns_name(self):
name = "TestNode"
packed = msgpack.packb([name.encode(), None, None])
b64 = base64.b64encode(packed).decode()
assert parse_lxmf_display_name(b64) == name
@pytest.mark.skipif(msgpack is None, reason="msgpack not available")
def test_empty_name_in_app_data(self):
packed = msgpack.packb([b"", None, None])
b64 = base64.b64encode(packed).decode()
result = parse_lxmf_display_name(b64)
assert result == ""
def test_garbage_base64(self):
result = parse_lxmf_display_name("!!!not-base64!!!")
assert isinstance(result, str)
@pytest.mark.skipif(msgpack is None, reason="msgpack not available")
def test_none_name_in_app_data_returns_default(self):
packed = msgpack.packb([None, None, None])
b64 = base64.b64encode(packed).decode()
assert parse_lxmf_display_name(b64) == "Anonymous Peer"
# ---------------------------------------------------------------------------
# Integration: announce + custom name + contact name resolution priority
# ---------------------------------------------------------------------------
class TestNameResolutionPriority:
"""Simulates the priority chain:
custom_display_name > announce app_data > contact name > 'Anonymous Peer'
"""
def test_custom_name_wins_over_announce(self, announce_dao):
dest = "f" * 32
announce_dao.upsert_announce(
_base_announce(dest=dest, app_data="AnnounceAlice")
)
announce_dao.upsert_custom_display_name(dest, "CustomAlice")
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
display = custom or row["app_data"] or "Anonymous Peer"
assert display == "CustomAlice"
def test_announce_used_when_no_custom(self, announce_dao):
dest = "f" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data="AnnounceOnly"))
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
display = custom or row["app_data"] or "Anonymous Peer"
assert display == "AnnounceOnly"
def test_fallback_to_anonymous_when_nothing(self, announce_dao):
dest = "f" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
display = custom or row["app_data"] or "Anonymous Peer"
assert display == "Anonymous Peer"
def test_contact_name_used_when_no_announce_no_custom(
self, announce_dao, contacts_dao, provider
):
dest = "f" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
contacts_dao.add_contact("ContactAlice", dest, lxmf_address=dest)
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
contact = contacts_dao.get_contact_by_identity_hash(dest)
contact_name = contact["name"] if contact else None
display = custom or row["app_data"] or contact_name or "Anonymous Peer"
assert display == "ContactAlice"
def test_clearing_custom_name_falls_back_to_announce(self, announce_dao):
dest = "f" * 32
announce_dao.upsert_announce(_base_announce(dest=dest, app_data="AnnounceName"))
announce_dao.upsert_custom_display_name(dest, "Custom")
announce_dao.delete_custom_display_name(dest)
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
display = custom or row["app_data"] or "Anonymous Peer"
assert display == "AnnounceName"
def test_wiping_announce_with_contact_still_resolves(
self, announce_dao, contacts_dao
):
"""After the COALESCE fix, this should not happen, but if app_data
was already NULL, the contact name should still be available."""
dest = "f" * 32
contacts_dao.add_contact("ContactFallback", dest, lxmf_address=dest)
announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
custom = announce_dao.get_custom_display_name(dest)
row = announce_dao.get_announce_by_hash(dest)
contact = contacts_dao.get_contact_by_identity_hash(dest)
contact_name = contact["name"] if contact else None
display = custom or row["app_data"] or contact_name or "Anonymous Peer"
assert display == "ContactFallback"
# ---------------------------------------------------------------------------
# Announce trim does not break name resolution
# ---------------------------------------------------------------------------
class TestAnnounceTrimSafety:
def test_trim_does_not_remove_active_announce(self, announce_dao):
for i in range(5):
announce_dao.upsert_announce(
_base_announce(dest=f"{i:032x}", app_data=f"name_{i}")
)
announce_dao.trim_announces_for_aspect("lxmf.delivery", max_rows=3)
remaining = announce_dao.get_announces(aspect="lxmf.delivery")
assert len(remaining) == 3
for r in remaining:
assert r["app_data"] is not None
assert r["app_data"].startswith("name_")
# ---------------------------------------------------------------------------
# Contacts + custom display name sync edge cases
# ---------------------------------------------------------------------------
class TestContactCustomNameSync:
def test_renaming_contact_and_custom_name_independently(
self, contacts_dao, announce_dao, provider
):
contacts_dao.add_contact("Alice", "ih20", lxmf_address="lx20")
row = provider.fetchone(
"SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih20",)
)
cid = row["id"]
announce_dao.upsert_custom_display_name("lx20", "Alice Custom")
contacts_dao.update_contact(cid, name="Alice Renamed")
assert announce_dao.get_custom_display_name("lx20") == "Alice Custom"
contact = contacts_dao.get_contact(cid)
assert contact["name"] == "Alice Renamed"
def test_synced_rename_updates_both(self, contacts_dao, announce_dao, provider):
"""Simulates what the UI should do: update both contact and custom name."""
contacts_dao.add_contact("Bob", "ih21", lxmf_address="lx21")
row = provider.fetchone(
"SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih21",)
)
cid = row["id"]
new_name = "Bob Renamed"
contacts_dao.update_contact(cid, name=new_name)
announce_dao.upsert_custom_display_name("lx21", new_name)
contact = contacts_dao.get_contact(cid)
custom = announce_dao.get_custom_display_name("lx21")
assert contact["name"] == new_name
assert custom == new_name
+478
View File
@@ -0,0 +1,478 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import MessagesPage from "@/components/messages/MessagesPage.vue";
import ContactsPage from "@/components/contacts/ContactsPage.vue";
import DialogUtils from "@/js/DialogUtils";
vi.mock("@/js/DialogUtils", () => ({
default: {
prompt: vi.fn(),
alert: vi.fn(),
confirm: vi.fn(() => Promise.resolve(true)),
},
}));
vi.mock("@/js/WebSocketConnection", () => ({
default: {
on: vi.fn(),
off: vi.fn(),
send: vi.fn(),
},
}));
vi.mock("qrcode", () => ({
default: {
toDataURL: vi.fn().mockResolvedValue("data:image/png;base64,test"),
},
}));
describe("MessagesPage display name protection", () => {
let axiosMock;
beforeEach(() => {
vi.clearAllMocks();
axiosMock = {
get: vi.fn(),
post: vi.fn(),
};
window.api = axiosMock;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config")
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
return Promise.resolve({ data: {} });
});
});
afterEach(() => {
delete window.api;
});
const mountMessagesPage = (props = { destinationHash: "" }) => {
return mount(MessagesPage, {
props,
global: {
mocks: {
$t: (key) => key,
$route: { query: {} },
$router: { replace: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
LoadingSpinner: true,
MessagesSidebar: {
template: '<div class="sidebar-stub"></div>',
props: ["conversations", "selectedDestinationHash"],
},
ConversationViewer: {
template: '<div class="viewer-stub"></div>',
props: ["selectedPeer", "myLxmfAddressHash"],
},
Modal: true,
},
},
});
};
it("does not overwrite a known display name with Anonymous Peer from announce", async () => {
const destHash = "a".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Real Name",
custom_display_name: null,
};
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "Anonymous Peer",
});
expect(wrapper.vm.peers[destHash].display_name).toBe("Real Name");
});
it("allows overwriting Anonymous Peer with a real name from announce", async () => {
const destHash = "b".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Anonymous Peer",
};
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "Newly Announced",
});
expect(wrapper.vm.peers[destHash].display_name).toBe("Newly Announced");
});
it("allows updating from one real name to another real name via announce", async () => {
const destHash = "c".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Old Name",
};
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "New Name",
});
expect(wrapper.vm.peers[destHash].display_name).toBe("New Name");
});
it("preserves known name when conversation list returns Anonymous Peer", async () => {
const destHash = "d".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Known Peer",
custom_display_name: null,
};
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/conversations")
return Promise.resolve({
data: {
conversations: [
{
destination_hash: destHash,
display_name: "Anonymous Peer",
custom_display_name: null,
},
],
},
});
if (url === "/api/v1/config")
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
return Promise.resolve({ data: {} });
});
await wrapper.vm.getConversations();
await wrapper.vm.$nextTick();
expect(wrapper.vm.peers[destHash].display_name).toBe("Known Peer");
});
it("accepts new display name from conversation list when peer was Anonymous", async () => {
const destHash = "e".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Anonymous Peer",
};
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/conversations")
return Promise.resolve({
data: {
conversations: [
{
destination_hash: destHash,
display_name: "Resolved Name",
custom_display_name: null,
},
],
},
});
if (url === "/api/v1/config")
return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
return Promise.resolve({ data: {} });
});
await wrapper.vm.getConversations();
await wrapper.vm.$nextTick();
expect(wrapper.vm.peers[destHash].display_name).toBe("Resolved Name");
});
it("does not overwrite name via resolvePeerDisplayName when server returns Anonymous", async () => {
const destHash = "f".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.conversations = [
{ destination_hash: destHash, display_name: "Original Name", custom_display_name: null },
];
wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Original Name" };
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/lxmf/conversations")
return Promise.resolve({
data: {
conversations: [
{
destination_hash: destHash,
display_name: "Anonymous Peer",
custom_display_name: null,
},
],
},
});
return Promise.resolve({ data: {} });
});
await wrapper.vm.resolvePeerDisplayName(destHash);
await wrapper.vm.$nextTick();
expect(wrapper.vm.conversations[0].display_name).toBe("Original Name");
expect(wrapper.vm.selectedPeer.display_name).toBe("Original Name");
});
it("handles new peer with no prior entry gracefully", async () => {
const destHash = "1".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "Anonymous Peer",
});
expect(wrapper.vm.peers[destHash].display_name).toBe("Anonymous Peer");
});
it("preserves custom_display_name when announce does not carry it", async () => {
const destHash = "2".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Announced Name",
custom_display_name: "My Custom Name",
};
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "Anonymous Peer",
});
expect(wrapper.vm.peers[destHash].custom_display_name).toBe("My Custom Name");
expect(wrapper.vm.peers[destHash].display_name).toBe("Announced Name");
});
it("server-provided custom_display_name in announce is authoritative", async () => {
const destHash = "3".repeat(32);
const wrapper = mountMessagesPage();
await wrapper.vm.$nextTick();
wrapper.vm.peers[destHash] = {
destination_hash: destHash,
display_name: "Old",
custom_display_name: "Stale Custom",
};
wrapper.vm.updatePeerFromAnnounce({
destination_hash: destHash,
display_name: "Anonymous Peer",
custom_display_name: "Server Custom",
});
expect(wrapper.vm.peers[destHash].custom_display_name).toBe("Server Custom");
expect(wrapper.vm.peers[destHash].display_name).toBe("Old");
});
});
describe("ContactsPage edit contact name", () => {
let axiosMock;
beforeEach(() => {
vi.clearAllMocks();
axiosMock = {
get: vi.fn(),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
window.api = axiosMock;
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/config")
return Promise.resolve({
data: {
config: {
lxmf_address_hash: "a".repeat(32),
identity_public_key: "b".repeat(128),
},
},
});
if (url === "/api/v1/telephone/contacts")
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
return Promise.resolve({ data: {} });
});
axiosMock.patch.mockResolvedValue({ data: { message: "Contact updated" } });
axiosMock.post.mockResolvedValue({ data: { message: "OK" } });
});
afterEach(() => {
delete window.api;
});
const mountPage = () =>
mount(ContactsPage, {
global: {
mocks: {
$t: (key) => key,
$router: { push: vi.fn() },
},
stubs: {
MaterialDesignIcon: true,
LxmfUserIcon: true,
},
},
});
it("editContactName updates both contact and custom display name", async () => {
DialogUtils.prompt.mockResolvedValue("Renamed Alice");
const wrapper = mountPage();
await wrapper.vm.$nextTick();
const contact = {
id: 42,
name: "Alice",
remote_identity_hash: "a".repeat(32),
lxmf_address: "a".repeat(32),
remote_destination_hash: "a".repeat(32),
};
await wrapper.vm.editContactName(contact);
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/42", {
name: "Renamed Alice",
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/destination/${"a".repeat(32)}/custom-display-name/update`,
{ display_name: "Renamed Alice" }
);
});
it("editContactName does nothing when user cancels prompt", async () => {
DialogUtils.prompt.mockResolvedValue(null);
const wrapper = mountPage();
await wrapper.vm.$nextTick();
await wrapper.vm.editContactName({ id: 1, name: "Alice" });
expect(axiosMock.patch).not.toHaveBeenCalled();
expect(axiosMock.post).not.toHaveBeenCalled();
});
it("editContactName does nothing when name unchanged", async () => {
DialogUtils.prompt.mockResolvedValue("Alice");
const wrapper = mountPage();
await wrapper.vm.$nextTick();
await wrapper.vm.editContactName({ id: 1, name: "Alice" });
expect(axiosMock.patch).not.toHaveBeenCalled();
});
it("editContactName skips custom display name when no dest hash", async () => {
DialogUtils.prompt.mockResolvedValue("New Name");
const wrapper = mountPage();
await wrapper.vm.$nextTick();
const contact = {
id: 99,
name: "Old Name",
remote_identity_hash: null,
lxmf_address: null,
};
await wrapper.vm.editContactName(contact);
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/99", {
name: "New Name",
});
expect(axiosMock.post).not.toHaveBeenCalled();
});
it("editContactName uses lxmf_address when remote_destination_hash absent", async () => {
DialogUtils.prompt.mockResolvedValue("Updated");
const wrapper = mountPage();
await wrapper.vm.$nextTick();
const contact = {
id: 10,
name: "Old",
remote_identity_hash: "i".repeat(32),
lxmf_address: "l".repeat(32),
};
await wrapper.vm.editContactName(contact);
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/destination/${"l".repeat(32)}/custom-display-name/update`,
{ display_name: "Updated" }
);
});
it("editContactName handles API error gracefully", async () => {
DialogUtils.prompt.mockResolvedValue("Fail Name");
axiosMock.patch.mockRejectedValue(new Error("Network error"));
const wrapper = mountPage();
await wrapper.vm.$nextTick();
const contact = { id: 5, name: "Before", remote_identity_hash: "x".repeat(32) };
await wrapper.vm.editContactName(contact);
expect(axiosMock.patch).toHaveBeenCalled();
});
it("editContactName with empty string still calls patch but not custom display name", async () => {
DialogUtils.prompt.mockResolvedValue("");
const wrapper = mountPage();
await wrapper.vm.$nextTick();
const contact = {
id: 7,
name: "Was Something",
remote_identity_hash: "r".repeat(32),
lxmf_address: "r".repeat(32),
};
await wrapper.vm.editContactName(contact);
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/7", {
name: "",
});
expect(axiosMock.post).not.toHaveBeenCalled();
});
it("editContactName with no contact id does nothing", async () => {
const wrapper = mountPage();
await wrapper.vm.$nextTick();
await wrapper.vm.editContactName({});
expect(DialogUtils.prompt).not.toHaveBeenCalled();
expect(axiosMock.patch).not.toHaveBeenCalled();
});
});
+3 -2
View File
@@ -17,6 +17,7 @@ describe("ToolsPage.vue", () => {
{ path: "/bots", name: "bots", component: { template: "div" } },
{ path: "/forwarder", name: "forwarder", component: { template: "div" } },
{ path: "/documentation", name: "documentation", component: { template: "div" } },
{ path: "/licenses", name: "licenses", component: { template: "div" } },
{ path: "/micron-editor", name: "micron-editor", component: { template: "div" } },
{ path: "/paper-message", name: "paper-message", component: { template: "div" } },
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
@@ -51,7 +52,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
expect(toolRows.length).toBe(17);
expect(toolRows.length).toBe(18);
});
it("filters tools based on search query", async () => {
@@ -76,6 +77,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
expect(wrapper.vm.filteredTools.length).toBe(17);
expect(wrapper.vm.filteredTools.length).toBe(18);
});
});