mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 19:02:18 +00:00
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:
5 files changed
+1411
-8
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user