mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 23:22:18 +00:00
feat(setup): add new packages 'lxmfy' and 'websockets' to cx_setup.py; refactor JSON output handling in build_community_interfaces_json.py; improve docstring clarity in move_wheels.py; update datetime import in slsa-predicate.py; enhance performance benchmarks with clearer argument passing; and refactor various test cases for improved readability and consistency.
This commit is contained in:
38 files changed
+1384
-101
No files matched your search
@@ -34,6 +34,8 @@ packages = [
|
||||
"RNS.Interfaces",
|
||||
"LXMF",
|
||||
"LXST",
|
||||
"lxmfy",
|
||||
"websockets",
|
||||
"pycparser",
|
||||
"cffi",
|
||||
"ply",
|
||||
|
||||
@@ -85,7 +85,8 @@ def main() -> int:
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(
|
||||
json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8",
|
||||
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"Wrote {len(out_list)} interfaces to {args.output}")
|
||||
return 0
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
def _source_uri() -> str:
|
||||
@@ -55,7 +55,7 @@ def main() -> None:
|
||||
run_id = os.environ.get("GITHUB_RUN_ID", "")
|
||||
attempt = os.environ.get("GITHUB_RUN_ATTEMPT", "1")
|
||||
workflow = os.environ.get("GITHUB_WORKFLOW", "")
|
||||
started = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
started = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
internal = {}
|
||||
if workflow:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Move Poetry-built wheels from dist/ to python-dist/ to avoid conflicts
|
||||
with Electron build artifacts.
|
||||
"""Move Poetry-built wheels from ``dist/`` into ``python-dist/``.
|
||||
|
||||
Avoids filename clashes with Electron build outputs.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
|
||||
@@ -177,7 +177,8 @@ class PerformanceBenchmarker:
|
||||
def run_trim():
|
||||
for _ in range(runs):
|
||||
self.db.announces.trim_announces_for_aspect(
|
||||
aspect, max(1, seed_count // 2)
|
||||
aspect,
|
||||
max(1, seed_count // 2),
|
||||
)
|
||||
|
||||
self.record_benchmark(
|
||||
|
||||
@@ -65,7 +65,13 @@ def test_count_matches_list_for_no_filters(dao):
|
||||
def test_search_filters(dao):
|
||||
ih = _id_hash()
|
||||
dao.insert(
|
||||
ih, "192.168.99.1", "UniqueSearchUA", LOGIN_PATH, "POST", "success", "detail-x",
|
||||
ih,
|
||||
"192.168.99.1",
|
||||
"UniqueSearchUA",
|
||||
LOGIN_PATH,
|
||||
"POST",
|
||||
"success",
|
||||
"detail-x",
|
||||
)
|
||||
found = dao.list_attempts(search="UniqueSearchUA", limit=50)
|
||||
assert any(r["user_agent"] == "UniqueSearchUA" for r in found)
|
||||
|
||||
@@ -30,7 +30,10 @@ from meshchatx.src.backend.database.access_attempts import (
|
||||
|
||||
|
||||
def _make_req(
|
||||
ip: str, ua: str, method: str = "POST", xff: str | None = None,
|
||||
ip: str,
|
||||
ua: str,
|
||||
method: str = "POST",
|
||||
xff: str | None = None,
|
||||
) -> SimpleNamespace:
|
||||
h = {"User-Agent": ua}
|
||||
if xff is not None:
|
||||
|
||||
@@ -54,7 +54,12 @@ def test_announce_max_stored_config_fuzz(max_stored):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet",
|
||||
reticulum,
|
||||
identity,
|
||||
b"new_dest",
|
||||
"lxmf.delivery",
|
||||
b"app_data",
|
||||
b"packet",
|
||||
)
|
||||
|
||||
mock_db.announces.upsert_announce.assert_called_once()
|
||||
|
||||
@@ -39,7 +39,12 @@ def test_trim_called_when_over_max_stored(mock_db, mock_config):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"new_dest", "lxmf.delivery", b"app_data", b"packet_hash",
|
||||
reticulum,
|
||||
identity,
|
||||
b"new_dest",
|
||||
"lxmf.delivery",
|
||||
b"app_data",
|
||||
b"packet_hash",
|
||||
)
|
||||
|
||||
mock_db.announces.upsert_announce.assert_called_once()
|
||||
@@ -57,7 +62,12 @@ def test_no_trim_without_config(mock_db):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
|
||||
reticulum,
|
||||
identity,
|
||||
b"dest",
|
||||
"lxmf.delivery",
|
||||
b"app_data",
|
||||
b"packet",
|
||||
)
|
||||
|
||||
mock_db.announces.upsert_announce.assert_called_once()
|
||||
@@ -74,7 +84,12 @@ def test_max_stored_none_skips_trim(mock_db, mock_config):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", b"packet",
|
||||
reticulum,
|
||||
identity,
|
||||
b"dest",
|
||||
"lxmf.delivery",
|
||||
b"app_data",
|
||||
b"packet",
|
||||
)
|
||||
|
||||
mock_db.announces.trim_announces_for_aspect.assert_not_called()
|
||||
@@ -147,7 +162,12 @@ def test_announce_handles_none_packet_hash(mock_db):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"dest", "lxmf.delivery", b"app_data", None,
|
||||
reticulum,
|
||||
identity,
|
||||
b"dest",
|
||||
"lxmf.delivery",
|
||||
b"app_data",
|
||||
None,
|
||||
)
|
||||
|
||||
mock_db.announces.upsert_announce.assert_called_once()
|
||||
|
||||
@@ -26,7 +26,12 @@ def test_upsert_announce(mock_db):
|
||||
identity.get_public_key.return_value = b"pub_key"
|
||||
|
||||
manager.upsert_announce(
|
||||
reticulum, identity, b"dest_hash", "aspect", b"app_data", b"packet_hash",
|
||||
reticulum,
|
||||
identity,
|
||||
b"dest_hash",
|
||||
"aspect",
|
||||
b"app_data",
|
||||
b"packet_hash",
|
||||
)
|
||||
|
||||
mock_db.announces.upsert_announce.assert_called_once()
|
||||
@@ -54,7 +59,9 @@ def test_get_filtered_announces_count(mock_db):
|
||||
manager = AnnounceManager(mock_db)
|
||||
mock_db.provider.fetchone.return_value = {"count": 5}
|
||||
count = manager.get_filtered_announces_count(
|
||||
aspect="test", query="q", blocked_identity_hashes=["b1"],
|
||||
aspect="test",
|
||||
query="q",
|
||||
blocked_identity_hashes=["b1"],
|
||||
)
|
||||
assert count == 5
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@ def test_archive_page_enforce_max_versions(mock_db):
|
||||
|
||||
# Should delete the 6th version (index 5)
|
||||
mock_db.provider.execute.assert_any_call(
|
||||
"DELETE FROM archived_pages WHERE id = ?", (6,),
|
||||
"DELETE FROM archived_pages WHERE id = ?",
|
||||
(6,),
|
||||
)
|
||||
|
||||
|
||||
@@ -69,5 +70,6 @@ def test_archive_page_enforce_storage_limit(mock_db):
|
||||
manager.archive_page("dest", "/path", "content", max_storage_gb=1)
|
||||
|
||||
mock_db.provider.execute.assert_any_call(
|
||||
"DELETE FROM archived_pages WHERE id = ?", (10,),
|
||||
"DELETE FROM archived_pages WHERE id = ?",
|
||||
(10,),
|
||||
)
|
||||
@@ -202,7 +202,8 @@ def test_transform_tcp_with_backbone_in_config_and_identity():
|
||||
st.fixed_dictionaries(
|
||||
{
|
||||
"id": st.one_of(
|
||||
st.none(), st.integers(min_value=-1000, max_value=10000),
|
||||
st.none(),
|
||||
st.integers(min_value=-1000, max_value=10000),
|
||||
),
|
||||
"name": st.text(max_size=40),
|
||||
"type": st.sampled_from(["backbone", "tcp", "i2p", "rnode", ""]),
|
||||
|
||||
@@ -111,7 +111,9 @@ class TestConcurrencyStress(unittest.TestCase):
|
||||
"SELECT COUNT(*) as count FROM lxmf_messages",
|
||||
)["count"]
|
||||
self.assertEqual(
|
||||
total, 5 * 50, "Total messages inserted doesn't match expected count",
|
||||
total,
|
||||
5 * 50,
|
||||
"Total messages inserted doesn't match expected count",
|
||||
)
|
||||
print(f"Stress test completed. Total messages inserted: {total}")
|
||||
|
||||
@@ -132,7 +134,8 @@ class TestConcurrencyStress(unittest.TestCase):
|
||||
|
||||
id_thread = threading.Thread(target=identity_worker)
|
||||
db_thread = threading.Thread(
|
||||
target=self.db_writer_worker, args=("id_collision",),
|
||||
target=self.db_writer_worker,
|
||||
args=("id_collision",),
|
||||
)
|
||||
|
||||
id_thread.start()
|
||||
@@ -152,7 +155,9 @@ class TestConcurrencyStress(unittest.TestCase):
|
||||
"SELECT COUNT(*) as count FROM lxmf_messages",
|
||||
)["count"]
|
||||
self.assertEqual(
|
||||
total_messages, 50, "Should have inserted 50 messages during collision test",
|
||||
total_messages,
|
||||
50,
|
||||
"Should have inserted 50 messages during collision test",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -226,7 +226,12 @@ class TestMiscDAOFuzzing:
|
||||
max_examples=40,
|
||||
)
|
||||
def test_add_notification_never_crashes(
|
||||
self, db, ntype, remote_hash, title, content,
|
||||
self,
|
||||
db,
|
||||
ntype,
|
||||
remote_hash,
|
||||
title,
|
||||
content,
|
||||
):
|
||||
db.misc.add_notification(ntype, remote_hash, title, content)
|
||||
notifications = db.misc.get_notifications()
|
||||
@@ -463,7 +468,9 @@ class TestMapDrawingsDAOFuzzing:
|
||||
st.builds(
|
||||
json.dumps,
|
||||
st.dictionaries(
|
||||
st.text(max_size=10), st.text(max_size=50), max_size=10,
|
||||
st.text(max_size=10),
|
||||
st.text(max_size=50),
|
||||
max_size=10,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -572,7 +579,11 @@ class TestMessageHandlerFuzzing:
|
||||
max_examples=40,
|
||||
)
|
||||
def test_get_conversation_messages_never_crashes(
|
||||
self, handler, dest, after_id, before_id,
|
||||
self,
|
||||
handler,
|
||||
dest,
|
||||
after_id,
|
||||
before_id,
|
||||
):
|
||||
results = handler.get_conversation_messages(
|
||||
"local_hash",
|
||||
|
||||
@@ -17,7 +17,9 @@ class TestDatabaseMigration(unittest.TestCase):
|
||||
self.identity_hash = "deadbeef"
|
||||
self.legacy_config_dir = os.path.join(self.test_dir, "legacy_config")
|
||||
self.legacy_db_subdir = os.path.join(
|
||||
self.legacy_config_dir, "identities", self.identity_hash,
|
||||
self.legacy_config_dir,
|
||||
"identities",
|
||||
self.identity_hash,
|
||||
)
|
||||
os.makedirs(self.legacy_db_subdir, exist_ok=True)
|
||||
self.legacy_db_path = os.path.join(self.legacy_db_subdir, "database.db")
|
||||
@@ -119,12 +121,15 @@ class TestDatabaseMigration(unittest.TestCase):
|
||||
|
||||
def test_migration_evolution(self):
|
||||
migrator = LegacyMigrator(
|
||||
self.db.provider, self.legacy_config_dir, self.identity_hash,
|
||||
self.db.provider,
|
||||
self.legacy_config_dir,
|
||||
self.identity_hash,
|
||||
)
|
||||
|
||||
# Check if should migrate
|
||||
self.assertTrue(
|
||||
migrator.should_migrate(), "Should detect legacy database for migration",
|
||||
migrator.should_migrate(),
|
||||
"Should detect legacy database for migration",
|
||||
)
|
||||
|
||||
# Perform migration
|
||||
@@ -136,7 +141,8 @@ class TestDatabaseMigration(unittest.TestCase):
|
||||
print(f"Config rows: {config_rows}")
|
||||
|
||||
config_val = self.db.provider.fetchone(
|
||||
"SELECT value FROM config WHERE key = ?", ("legacy_key",),
|
||||
"SELECT value FROM config WHERE key = ?",
|
||||
("legacy_key",),
|
||||
)
|
||||
self.assertIsNotNone(config_val, "legacy_key should have been migrated")
|
||||
self.assertEqual(config_val["value"], "legacy_value")
|
||||
@@ -147,7 +153,8 @@ class TestDatabaseMigration(unittest.TestCase):
|
||||
self.assertEqual(ann_count, 1)
|
||||
|
||||
msg = self.db.provider.fetchone(
|
||||
"SELECT * FROM lxmf_messages WHERE hash = ?", ("msg1",),
|
||||
"SELECT * FROM lxmf_messages WHERE hash = ?",
|
||||
("msg1",),
|
||||
)
|
||||
self.assertIsNotNone(msg)
|
||||
self.assertEqual(msg["title"], "Old Title")
|
||||
|
||||
@@ -128,7 +128,9 @@ def test_contacts_only_accepts_matching_contact(policy_app):
|
||||
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
|
||||
CALLER_HASH_HEX,
|
||||
)
|
||||
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
|
||||
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
|
||||
caller
|
||||
)
|
||||
policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
|
||||
async_utils.run_async.assert_called_once()
|
||||
|
||||
@@ -175,7 +177,9 @@ def test_when_policy_off_stranger_rings(policy_app):
|
||||
_run_incoming(policy_app, caller)
|
||||
|
||||
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
|
||||
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)
|
||||
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
|
||||
caller
|
||||
)
|
||||
async_utils.run_async.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
|
||||
f.write(bytes(range(256)))
|
||||
# log2(256) = 8
|
||||
self.assertAlmostEqual(
|
||||
self.manager._calculate_entropy(max_entropy_file), 8.0, places=5,
|
||||
self.manager._calculate_entropy(max_entropy_file),
|
||||
8.0,
|
||||
places=5,
|
||||
)
|
||||
|
||||
@settings(suppress_health_check=[HealthCheck.too_slow], deadline=None)
|
||||
@@ -125,7 +127,8 @@ class TestIntegrityManagerExtensive(unittest.TestCase):
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.touch()
|
||||
self.assertTrue(
|
||||
self.manager._should_ignore(str(rel_path)), f"Failed to ignore {v}",
|
||||
self.manager._should_ignore(str(rel_path)),
|
||||
f"Failed to ignore {v}",
|
||||
)
|
||||
|
||||
def test_critical_file_protection(self):
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""
|
||||
Integration tests for LXMF messaging and Reticulum communication.
|
||||
"""Integration tests for LXMF messaging and Reticulum communication.
|
||||
|
||||
Covers stamp proof-of-work, message packing/unpacking, signature validation,
|
||||
delivery pipelines (direct, opportunistic, propagated), propagation node
|
||||
@@ -86,8 +85,8 @@ def _cleanup():
|
||||
# 1. Stamp proof-of-work (in-process, no Reticulum needed)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStampSolving:
|
||||
|
||||
class TestStampSolving:
|
||||
def test_workblock_deterministic(self):
|
||||
material = os.urandom(32)
|
||||
wb1 = LXStamper.stamp_workblock(material, expand_rounds=10)
|
||||
@@ -137,19 +136,22 @@ class TestStampSolving:
|
||||
def test_propagation_node_stamp_rounds(self):
|
||||
mid = os.urandom(32)
|
||||
stamp, value = LXStamper.generate_stamp(
|
||||
mid, stamp_cost=2,
|
||||
mid,
|
||||
stamp_cost=2,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
|
||||
)
|
||||
assert stamp is not None
|
||||
wb = LXStamper.stamp_workblock(
|
||||
mid, expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
|
||||
mid,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
|
||||
)
|
||||
assert LXStamper.stamp_valid(stamp, 2, wb)
|
||||
|
||||
def test_peering_key_generation_and_validation(self):
|
||||
peer_id = os.urandom(32)
|
||||
stamp, _ = LXStamper.generate_stamp(
|
||||
peer_id, stamp_cost=2,
|
||||
peer_id,
|
||||
stamp_cost=2,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
|
||||
)
|
||||
assert LXStamper.validate_peering_key(peer_id, stamp, 2)
|
||||
@@ -158,18 +160,21 @@ class TestStampSolving:
|
||||
peer_a = os.urandom(32)
|
||||
peer_b = os.urandom(32)
|
||||
stamp, _ = LXStamper.generate_stamp(
|
||||
peer_a, stamp_cost=8,
|
||||
peer_a,
|
||||
stamp_cost=8,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PEERING,
|
||||
)
|
||||
assert not LXStamper.validate_peering_key(peer_b, stamp, 8)
|
||||
|
||||
def test_pn_stamp_valid_transient_data(self):
|
||||
from LXMF.LXMessage import LXMessage
|
||||
|
||||
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
|
||||
fake_lxm = os.urandom(overhead + 64)
|
||||
t_id = RNS.Identity.full_hash(fake_lxm)
|
||||
stamp, _ = LXStamper.generate_stamp(
|
||||
t_id, stamp_cost=2,
|
||||
t_id,
|
||||
stamp_cost=2,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
|
||||
)
|
||||
td = fake_lxm + stamp
|
||||
@@ -181,6 +186,7 @@ class TestStampSolving:
|
||||
|
||||
def test_pn_stamp_rejected_bad_stamp(self):
|
||||
from LXMF.LXMessage import LXMessage
|
||||
|
||||
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
|
||||
fake_lxm = os.urandom(overhead + 64)
|
||||
bad_stamp = os.urandom(LXStamper.STAMP_SIZE)
|
||||
@@ -190,13 +196,15 @@ class TestStampSolving:
|
||||
|
||||
def test_pn_stamp_batch_validation(self):
|
||||
from LXMF.LXMessage import LXMessage
|
||||
|
||||
overhead = LXMessage.LXMF_OVERHEAD + LXStamper.STAMP_SIZE
|
||||
items = []
|
||||
for _ in range(5):
|
||||
fake = os.urandom(overhead + 64)
|
||||
t_id = RNS.Identity.full_hash(fake)
|
||||
stamp, _ = LXStamper.generate_stamp(
|
||||
t_id, stamp_cost=2,
|
||||
t_id,
|
||||
stamp_cost=2,
|
||||
expand_rounds=LXStamper.WORKBLOCK_EXPAND_ROUNDS_PN,
|
||||
)
|
||||
items.append(fake + stamp)
|
||||
@@ -222,8 +230,8 @@ class TestStampSolving:
|
||||
# 2. LXMessage protocol (subprocess, real crypto)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLXMessageProtocol:
|
||||
|
||||
class TestLXMessageProtocol:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_pack_unpack_roundtrip(self):
|
||||
@@ -514,8 +522,8 @@ class TestLXMessageProtocol:
|
||||
# 3. LXMRouter delivery pipeline (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestLXMRouterDelivery:
|
||||
|
||||
class TestLXMRouterDelivery:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_direct_delivery_via_lxmf_delivery(self):
|
||||
@@ -840,8 +848,8 @@ class TestLXMRouterDelivery:
|
||||
# 4. Propagation node operations (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestPropagationNode:
|
||||
|
||||
class TestPropagationNode:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_enable_disable_propagation(self):
|
||||
@@ -1031,8 +1039,8 @@ class TestPropagationNode:
|
||||
# 5. LXMessage state machine and callbacks (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMessageStateAndCallbacks:
|
||||
|
||||
class TestMessageStateAndCallbacks:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_message_states_enum(self):
|
||||
@@ -1141,8 +1149,8 @@ class TestMessageStateAndCallbacks:
|
||||
# 6. Multi-field message integrity (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestMessageFieldIntegrity:
|
||||
|
||||
class TestMessageFieldIntegrity:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_fields_survive_roundtrip(self):
|
||||
@@ -1232,8 +1240,8 @@ class TestMessageFieldIntegrity:
|
||||
# 7. URI ingestion (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestURIIngestion:
|
||||
|
||||
class TestURIIngestion:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_paper_uri_roundtrip(self):
|
||||
@@ -1284,8 +1292,8 @@ class TestURIIngestion:
|
||||
# 8. Router configuration and state (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestRouterConfiguration:
|
||||
|
||||
class TestRouterConfiguration:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_single_delivery_identity_per_router(self):
|
||||
@@ -1419,8 +1427,8 @@ class TestRouterConfiguration:
|
||||
# 9. End-to-end two-router communication (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTwoRouterCommunication:
|
||||
|
||||
class TestTwoRouterCommunication:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_bidirectional_protocol_delivery(self):
|
||||
@@ -1556,8 +1564,8 @@ class TestTwoRouterCommunication:
|
||||
# 10. Reticulum identity and destination basics (subprocess)
|
||||
# ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestReticulumPrimitives:
|
||||
|
||||
class TestReticulumPrimitives:
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.skipif(not _RUN, reason="Set MESHCHAT_LIVE_RETICULUM=1")
|
||||
def test_identity_creation_and_key_operations(self):
|
||||
|
||||
@@ -258,10 +258,13 @@ async def test_lxm_ingest_uri_lxma_adds_contact(mock_app):
|
||||
fake_identity.hash = bytes.fromhex("bb" * 16)
|
||||
fake_identity.load_public_key.return_value = True
|
||||
|
||||
with patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
), patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity):
|
||||
with (
|
||||
patch(
|
||||
"meshchatx.meshchat.AsyncUtils.run_async",
|
||||
side_effect=lambda coro: asyncio.create_task(coro),
|
||||
),
|
||||
patch("meshchatx.meshchat.RNS.Identity", return_value=fake_identity),
|
||||
):
|
||||
await mock_app.on_websocket_data_received(
|
||||
mock_client,
|
||||
{
|
||||
@@ -359,10 +362,12 @@ async def test_on_lxmf_sending_state_updated(mock_app):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"meshchatx.meshchat.convert_lxmf_message_to_dict", return_value={"h": "v"},
|
||||
"meshchatx.meshchat.convert_lxmf_message_to_dict",
|
||||
return_value={"h": "v"},
|
||||
),
|
||||
patch(
|
||||
"meshchatx.meshchat.convert_lxmf_state_to_string", return_value="delivered",
|
||||
"meshchatx.meshchat.convert_lxmf_state_to_string",
|
||||
return_value="delivered",
|
||||
),
|
||||
patch("meshchatx.meshchat.AsyncUtils.run_async") as mock_run_async,
|
||||
):
|
||||
@@ -414,7 +419,8 @@ def test_on_lxmf_sending_failed_no_propagation(mock_app):
|
||||
|
||||
mock_app.on_lxmf_sending_failed(mock_msg)
|
||||
mock_app.on_lxmf_sending_state_updated.assert_called_once_with(
|
||||
mock_msg, context=None,
|
||||
mock_msg,
|
||||
context=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -466,9 +472,12 @@ def test_convert_webm_opus_to_ogg_ffmpeg_fails(mock_app):
|
||||
def test_convert_webm_opus_to_ogg_exception(mock_app):
|
||||
webm_data = b"\x1a\x45\xdf\xa3" + b"\x00" * 100
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/ffmpeg"), patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/ffmpeg"),
|
||||
patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="ffmpeg", timeout=30),
|
||||
),
|
||||
):
|
||||
result = mock_app._convert_webm_opus_to_ogg(webm_data)
|
||||
|
||||
@@ -512,7 +521,9 @@ async def _run_send(app, destination_hash="aa" * 16, **kwargs):
|
||||
patch("meshchatx.meshchat.AsyncUtils.run_async"),
|
||||
):
|
||||
await app.send_message(
|
||||
destination_hash=destination_hash, content="hi", **kwargs,
|
||||
destination_hash=destination_hash,
|
||||
content="hi",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
return fake_lxm
|
||||
|
||||
@@ -31,7 +31,8 @@ def test_upsert_lxmf_message(message_dao, mock_provider):
|
||||
def test_get_lxmf_message_by_hash(message_dao, mock_provider):
|
||||
message_dao.get_lxmf_message_by_hash("hash1")
|
||||
mock_provider.fetchone.assert_called_with(
|
||||
"SELECT * FROM lxmf_messages WHERE hash = ?", ("hash1",),
|
||||
"SELECT * FROM lxmf_messages WHERE hash = ?",
|
||||
("hash1",),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,10 @@ def test_get_conversations_base(mock_db):
|
||||
def test_get_conversations_with_filters(mock_db):
|
||||
handler = MessageHandler(mock_db)
|
||||
handler.get_conversations(
|
||||
"local", search="test", filter_unread=True, filter_failed=True,
|
||||
"local",
|
||||
search="test",
|
||||
filter_unread=True,
|
||||
filter_failed=True,
|
||||
)
|
||||
|
||||
args, _ = mock_db.provider.fetchall.call_args
|
||||
|
||||
@@ -29,7 +29,12 @@ def downloader():
|
||||
on_failure = MagicMock()
|
||||
on_progress = MagicMock()
|
||||
return NomadnetDownloader(
|
||||
b"dest", "/path", "data", on_success, on_failure, on_progress,
|
||||
b"dest",
|
||||
"/path",
|
||||
"data",
|
||||
on_success,
|
||||
on_failure,
|
||||
on_progress,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,7 +125,8 @@ async def test_download_uses_path_wait_cache_hit(downloader):
|
||||
):
|
||||
with patch.object(downloader, "link_established") as mock_established:
|
||||
await downloader.download(
|
||||
path_lookup_timeout=5, link_establishment_timeout=5,
|
||||
path_lookup_timeout=5,
|
||||
link_establishment_timeout=5,
|
||||
)
|
||||
|
||||
mock_established.assert_called_once_with(mock_link)
|
||||
|
||||
@@ -82,7 +82,9 @@ def test_app_info_dependency_keys_resolve_in_dev_env(package: str):
|
||||
assert v != "unknown", f"{package} must resolve when installed"
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+")
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 13), reason="audioop-lts only on Python 3.13+"
|
||||
)
|
||||
def test_audioop_lts_resolves_when_applicable():
|
||||
v = ReticulumMeshChat.get_package_version("audioop-lts")
|
||||
assert v != "unknown"
|
||||
@@ -268,7 +268,8 @@ def test_interface_config_parser_no_crash(text):
|
||||
st.text(
|
||||
min_size=1,
|
||||
alphabet=st.characters(
|
||||
blacklist_categories=("Cc", "Cs"), blacklist_characters="[]",
|
||||
blacklist_categories=("Cc", "Cs"),
|
||||
blacklist_characters="[]",
|
||||
),
|
||||
).filter(lambda x: x.strip() == x and x),
|
||||
min_size=1,
|
||||
@@ -279,7 +280,8 @@ def test_interface_config_parser_no_crash(text):
|
||||
st.text(
|
||||
min_size=1,
|
||||
alphabet=st.characters(
|
||||
blacklist_categories=("Cc", "Cs"), blacklist_characters="[]=",
|
||||
blacklist_categories=("Cc", "Cs"),
|
||||
blacklist_characters="[]=",
|
||||
),
|
||||
).filter(lambda x: x.strip() == x and x),
|
||||
min_size=1,
|
||||
@@ -733,7 +735,11 @@ def test_message_fields_have_attachments_robustness(fields_json):
|
||||
lxmf_fields=st.dictionaries(
|
||||
keys=st.integers(),
|
||||
values=st.one_of(
|
||||
st.text(), st.binary(), st.integers(), st.booleans(), st.none(),
|
||||
st.text(),
|
||||
st.binary(),
|
||||
st.integers(),
|
||||
st.booleans(),
|
||||
st.none(),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -846,7 +852,12 @@ def test_convert_db_lxmf_message_to_dict_extended_robustness(
|
||||
),
|
||||
)
|
||||
def test_lxmf_utils_conversions_robustness(
|
||||
state_val, method_val, title, content, timestamp, fields,
|
||||
state_val,
|
||||
method_val,
|
||||
title,
|
||||
content,
|
||||
timestamp,
|
||||
fields,
|
||||
):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -980,7 +991,12 @@ class TestCrashRecoveryMathProperties:
|
||||
derandomize=True,
|
||||
)
|
||||
def test_system_entropy_always_finite(
|
||||
self, low_memory, config_missing, config_invalid, db_type, available_mem_mb,
|
||||
self,
|
||||
low_memory,
|
||||
config_missing,
|
||||
config_invalid,
|
||||
db_type,
|
||||
available_mem_mb,
|
||||
):
|
||||
"""Entropy and divergence must always be finite floats for any diagnosis."""
|
||||
import math as m
|
||||
@@ -1051,7 +1067,9 @@ class TestCrashRecoveryMathProperties:
|
||||
|
||||
@given(
|
||||
counts=st.lists(
|
||||
st.integers(min_value=0, max_value=50), min_size=1, max_size=10,
|
||||
st.integers(min_value=0, max_value=50),
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
),
|
||||
)
|
||||
@settings(derandomize=True, deadline=None, max_examples=50)
|
||||
|
||||
@@ -32,7 +32,10 @@ def test_rncp_handler_init(rncp_handler, mock_reticulum, mock_identity):
|
||||
@patch("meshchatx.src.backend.rncp_handler.RNS.Destination")
|
||||
@patch("meshchatx.src.backend.rncp_handler.RNS.Reticulum")
|
||||
def test_setup_receive_destination(
|
||||
mock_rns_reticulum, mock_dest, mock_identity_class, rncp_handler,
|
||||
mock_rns_reticulum,
|
||||
mock_dest,
|
||||
mock_identity_class,
|
||||
rncp_handler,
|
||||
):
|
||||
mock_rns_reticulum.identitypath = "/tmp/rns/identities"
|
||||
mock_id_obj = MagicMock()
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Integration tests for conversation list search and announce search filtering."""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from meshchatx.src.backend.announce_manager import (
|
||||
filter_announced_dicts_by_search_query,
|
||||
)
|
||||
from meshchatx.src.backend.message_handler import MessageHandler
|
||||
from tests.backend.test_performance_hotpaths import make_message
|
||||
|
||||
|
||||
def test_get_conversations_search_finds_peer_via_older_message(db):
|
||||
handler = MessageHandler(db)
|
||||
peer = secrets.token_hex(16)
|
||||
with db.provider:
|
||||
m_old = make_message(peer, 1)
|
||||
m_old["content"] = "unique_old_body_xyz"
|
||||
m_old["title"] = "t1"
|
||||
m_old["timestamp"] = time.time() - 100
|
||||
|
||||
m_new = make_message(peer, 2)
|
||||
m_new["content"] = "latest"
|
||||
m_new["title"] = "t2"
|
||||
m_new["timestamp"] = time.time()
|
||||
|
||||
db.messages.upsert_lxmf_message(m_old)
|
||||
db.messages.upsert_lxmf_message(m_new)
|
||||
|
||||
rows = handler.get_conversations("local", search="unique_old_body")
|
||||
dest_hashes = {r["peer_hash"] for r in rows}
|
||||
assert peer in dest_hashes
|
||||
|
||||
|
||||
def test_get_conversations_search_matches_custom_display_name(db):
|
||||
handler = MessageHandler(db)
|
||||
peer = secrets.token_hex(16)
|
||||
with db.provider:
|
||||
db.messages.upsert_lxmf_message(make_message(peer, 0))
|
||||
db.announces.upsert_custom_display_name(peer, "CustomDisplayUniqueZ")
|
||||
|
||||
rows = handler.get_conversations("local", search="CustomDisplayUnique")
|
||||
assert any(r["peer_hash"] == peer for r in rows)
|
||||
|
||||
|
||||
def test_filter_announced_dicts_by_search_query_display_name():
|
||||
items = [
|
||||
{
|
||||
"display_name": "AlphaOnlyName",
|
||||
"destination_hash": "c" * 32,
|
||||
"identity_hash": "e" * 32,
|
||||
},
|
||||
{
|
||||
"display_name": "BetaOtherName",
|
||||
"destination_hash": "d" * 32,
|
||||
"identity_hash": "e" * 32,
|
||||
},
|
||||
]
|
||||
out = filter_announced_dicts_by_search_query(items, "AlphaOnly")
|
||||
assert len(out) == 1
|
||||
assert out[0]["destination_hash"] == "c" * 32
|
||||
|
||||
|
||||
def test_filter_announced_dicts_by_search_query_custom_display_name():
|
||||
items = [
|
||||
{
|
||||
"display_name": "Anonymous Peer",
|
||||
"custom_display_name": "CustomNickUniqueSearch",
|
||||
"destination_hash": "f" * 32,
|
||||
"identity_hash": "1" * 32,
|
||||
},
|
||||
]
|
||||
out = filter_announced_dicts_by_search_query(items, "customnickunique")
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_filter_announced_dicts_by_search_query_destination_hash_substring():
|
||||
dest = "ab" + "0" * 30
|
||||
items = [
|
||||
{"display_name": "X", "destination_hash": dest, "identity_hash": "y" * 32},
|
||||
]
|
||||
out = filter_announced_dicts_by_search_query(items, "ab00")
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_filter_announced_dicts_by_search_query_case_insensitive():
|
||||
items = [
|
||||
{"display_name": "CamelCaseName", "destination_hash": "z" * 32},
|
||||
]
|
||||
out = filter_announced_dicts_by_search_query(items, "camelcase")
|
||||
assert len(out) == 1
|
||||
@@ -1394,7 +1394,10 @@ def test_nomadnet_page_archive_load_fuzzing(mock_app, archive_id):
|
||||
),
|
||||
)
|
||||
def test_nomadnet_page_archive_add_fuzzing(
|
||||
mock_app, destination_hash, page_path, content,
|
||||
mock_app,
|
||||
destination_hash,
|
||||
page_path,
|
||||
content,
|
||||
):
|
||||
"""Fuzz nomadnet.page.archive.add WebSocket handler and archive_page."""
|
||||
import asyncio
|
||||
@@ -1465,7 +1468,10 @@ def test_nomadnet_file_download_fuzzing(mock_app, destination_hash, file_path):
|
||||
),
|
||||
)
|
||||
def test_nomadnet_page_download_fuzzing(
|
||||
mock_app, destination_hash, page_path, field_data,
|
||||
mock_app,
|
||||
destination_hash,
|
||||
page_path,
|
||||
field_data,
|
||||
):
|
||||
"""Fuzz nomadnet.page.download WebSocket handler (page_path with backtick, field_data)."""
|
||||
import asyncio
|
||||
@@ -2284,7 +2290,8 @@ class TestLxmfFieldHardening:
|
||||
pass
|
||||
|
||||
@settings(
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture], deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
deadline=None,
|
||||
)
|
||||
@given(
|
||||
fields_data=st.dictionaries(
|
||||
@@ -2412,7 +2419,8 @@ class TestStrangerAttachmentBlocking:
|
||||
"""Text-only messages from strangers are delivered normally."""
|
||||
source_hash = os.urandom(16)
|
||||
mock_msg = self._make_mock_message(
|
||||
source_hash=source_hash, with_attachments=False,
|
||||
source_hash=source_hash,
|
||||
with_attachments=False,
|
||||
)
|
||||
|
||||
mock_app.config.block_attachments_from_strangers.get.return_value = True
|
||||
|
||||
@@ -71,14 +71,18 @@ class _Query:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_v1_telephone_voicemail_status_json_contract(mock_rns_minimal, temp_dir):
|
||||
async def test_api_v1_telephone_voicemail_status_json_contract(
|
||||
mock_rns_minimal, temp_dir
|
||||
):
|
||||
with patch("meshchatx.meshchat.generate_ssl_certificate"):
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=mock_rns_minimal,
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
handler = _find_handler(app_instance, "/api/v1/telephone/voicemail/status", "GET")
|
||||
handler = _find_handler(
|
||||
app_instance, "/api/v1/telephone/voicemail/status", "GET"
|
||||
)
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
response = await handler(request)
|
||||
@@ -104,7 +108,9 @@ async def test_api_v1_telephone_voicemails_json_contract(mock_rns_minimal, temp_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, temp_dir):
|
||||
async def test_api_v1_telephone_ringtones_list_json_contract(
|
||||
mock_rns_minimal, temp_dir
|
||||
):
|
||||
with patch("meshchatx.meshchat.generate_ssl_certificate"):
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=mock_rns_minimal,
|
||||
@@ -120,14 +126,18 @@ async def test_api_v1_telephone_ringtones_list_json_contract(mock_rns_minimal, t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_v1_telephone_ringtones_status_json_contract(mock_rns_minimal, temp_dir):
|
||||
async def test_api_v1_telephone_ringtones_status_json_contract(
|
||||
mock_rns_minimal, temp_dir
|
||||
):
|
||||
with patch("meshchatx.meshchat.generate_ssl_certificate"):
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=mock_rns_minimal,
|
||||
storage_dir=temp_dir,
|
||||
reticulum_config_dir=temp_dir,
|
||||
)
|
||||
handler = _find_handler(app_instance, "/api/v1/telephone/ringtones/status", "GET")
|
||||
handler = _find_handler(
|
||||
app_instance, "/api/v1/telephone/ringtones/status", "GET"
|
||||
)
|
||||
assert handler is not None
|
||||
request = MagicMock()
|
||||
request.query = _Query({})
|
||||
@@ -154,7 +164,9 @@ async def test_api_v1_telephone_contacts_list_json_contract(mock_rns_minimal, te
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_v1_telephone_contacts_check_json_contract(mock_rns_minimal, temp_dir):
|
||||
async def test_api_v1_telephone_contacts_check_json_contract(
|
||||
mock_rns_minimal, temp_dir
|
||||
):
|
||||
with patch("meshchatx.meshchat.generate_ssl_certificate"):
|
||||
app_instance = ReticulumMeshChat(
|
||||
identity=mock_rns_minimal,
|
||||
|
||||
@@ -40,12 +40,16 @@ def test_init_telephone(mock_tel_class, tel_manager):
|
||||
|
||||
|
||||
@patch("meshchatx.src.backend.telephone_manager.Telephone")
|
||||
def test_init_telephone_applies_config_audio_profile(mock_tel_class, mock_identity, tmp_path):
|
||||
def test_init_telephone_applies_config_audio_profile(
|
||||
mock_tel_class, mock_identity, tmp_path
|
||||
):
|
||||
storage_dir = tmp_path / "tel"
|
||||
storage_dir.mkdir()
|
||||
cfg = MagicMock()
|
||||
cfg.telephone_audio_profile_id.get.return_value = 96
|
||||
tm = TelephoneManager(mock_identity, config_manager=cfg, storage_dir=str(storage_dir))
|
||||
tm = TelephoneManager(
|
||||
mock_identity, config_manager=cfg, storage_dir=str(storage_dir)
|
||||
)
|
||||
tm.init_telephone()
|
||||
mock_tel_class.return_value.switch_profile.assert_called_with(96)
|
||||
|
||||
|
||||
@@ -74,7 +74,10 @@ def test_translate_argos_cli(mock_run):
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/argos-translate"):
|
||||
result = handler.translate_text(
|
||||
"Hello", source_lang="en", target_lang="es", use_argos=True,
|
||||
"Hello",
|
||||
source_lang="en",
|
||||
target_lang="es",
|
||||
use_argos=True,
|
||||
)
|
||||
assert result["translated_text"] == "Hola"
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ def test_find_bundled_binary_not_frozen(voicemail_manager):
|
||||
|
||||
def test_find_espeak_shutil(voicemail_manager):
|
||||
with patch(
|
||||
"shutil.which", side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
|
||||
"shutil.which",
|
||||
side_effect=lambda x: f"/usr/bin/{x}" if "espeak" in x else None,
|
||||
):
|
||||
path = voicemail_manager._find_espeak()
|
||||
assert "espeak" in path
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import CallPage from "@/components/call/CallPage.vue";
|
||||
|
||||
@@ -26,13 +26,48 @@ describe("CallPage.vue", () => {
|
||||
if (url.includes("/api/v1/telephone/history")) return Promise.resolve({ data: { call_history: [] } });
|
||||
if (url.includes("/api/v1/announces")) return Promise.resolve({ data: { announces: [] } });
|
||||
if (url.includes("/api/v1/telephone/status")) return Promise.resolve({ data: { active_call: null } });
|
||||
if (url.includes("/api/v1/telephone/voicemail/status"))
|
||||
return Promise.resolve({ data: { has_espeak: false } });
|
||||
if (url.includes("/api/v1/telephone/ringtones/status"))
|
||||
return Promise.resolve({ data: { enabled: true } });
|
||||
if (url.includes("/api/v1/telephone/ringtones")) return Promise.resolve({ data: { ringtones: [] } });
|
||||
if (url.includes("/api/v1/telephone/voicemail/status")) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
has_espeak: false,
|
||||
has_ffmpeg: false,
|
||||
is_recording: false,
|
||||
is_greeting_recording: false,
|
||||
has_greeting: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/voicemails")) {
|
||||
return Promise.resolve({ data: { voicemails: [], unread_count: 0 } });
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/ringtones/status")) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
has_custom_ringtone: false,
|
||||
enabled: true,
|
||||
filename: null,
|
||||
id: null,
|
||||
volume: 0.5,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/ringtones/") && url.includes("/audio")) {
|
||||
return Promise.resolve({ data: new ArrayBuffer(0) });
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/ringtones")) {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/audio-profiles"))
|
||||
return Promise.resolve({ data: { audio_profiles: [], default_audio_profile_id: null } });
|
||||
if (url.includes("/api/v1/telephone/contacts/export")) {
|
||||
return Promise.resolve({ data: { contacts: [] } });
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/contacts/check/")) {
|
||||
return Promise.resolve({ data: { is_contact: false, contact: null } });
|
||||
}
|
||||
if (url.includes("/api/v1/telephone/contacts")) {
|
||||
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
|
||||
}
|
||||
if (url.includes("/api/v1/contacts")) return Promise.resolve({ data: { contacts: [] } });
|
||||
|
||||
return Promise.resolve({ data: defaultData });
|
||||
@@ -226,4 +261,85 @@ describe("CallPage.vue", () => {
|
||||
await wrapper.vm.ensureWebAudio({ enabled: true });
|
||||
expect(stop).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getContacts maps telephone contacts list and hydrates visuals", async () => {
|
||||
const wrapper = mountCallPage();
|
||||
await flushPromises();
|
||||
const hydrate = vi.spyOn(wrapper.vm, "hydrateContactVisuals");
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
contacts: [{ id: 1, name: "Sam", remote_identity_hash: "ab".repeat(16) }],
|
||||
total_count: 2,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.getContacts();
|
||||
expect(wrapper.vm.contacts[0].name).toBe("Sam");
|
||||
expect(hydrate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getVoicemails maps voicemails and unread_count", async () => {
|
||||
const wrapper = mountCallPage();
|
||||
await flushPromises();
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
voicemails: [{ id: 9, remote_identity_hash: "cd".repeat(16), is_read: 0 }],
|
||||
unread_count: 1,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.getVoicemails();
|
||||
expect(wrapper.vm.voicemails).toHaveLength(1);
|
||||
expect(wrapper.vm.unreadVoicemailsCount).toBe(1);
|
||||
});
|
||||
|
||||
it("getRingtones stores API array on ringtones", async () => {
|
||||
const wrapper = mountCallPage();
|
||||
await flushPromises();
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
filename: "x.opus",
|
||||
display_name: "Test",
|
||||
is_primary: true,
|
||||
created_at: "2024-01-01",
|
||||
},
|
||||
],
|
||||
});
|
||||
await wrapper.vm.getRingtones();
|
||||
expect(wrapper.vm.ringtones).toHaveLength(1);
|
||||
expect(wrapper.vm.ringtones[0].filename).toBe("x.opus");
|
||||
});
|
||||
|
||||
it("getVoicemailStatus stores voicemail status payload", async () => {
|
||||
const wrapper = mountCallPage();
|
||||
await flushPromises();
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
has_espeak: true,
|
||||
has_ffmpeg: false,
|
||||
is_recording: false,
|
||||
is_greeting_recording: false,
|
||||
has_greeting: true,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.getVoicemailStatus();
|
||||
expect(wrapper.vm.voicemailStatus.has_greeting).toBe(true);
|
||||
});
|
||||
|
||||
it("getRingtoneStatus stores ringtone status payload", async () => {
|
||||
const wrapper = mountCallPage();
|
||||
await flushPromises();
|
||||
axiosMock.get.mockResolvedValueOnce({
|
||||
data: {
|
||||
has_custom_ringtone: true,
|
||||
enabled: true,
|
||||
filename: "ring.opus",
|
||||
id: 3,
|
||||
volume: 0.8,
|
||||
},
|
||||
});
|
||||
await wrapper.vm.getRingtoneStatus();
|
||||
expect(wrapper.vm.ringtoneStatus.id).toBe(3);
|
||||
expect(wrapper.vm.ringtoneStatus.volume).toBe(0.8);
|
||||
});
|
||||
});
|
||||
@@ -24,21 +24,28 @@ describe("CommandPalette.vue", () => {
|
||||
|
||||
GlobalEmitter.off("sync-propagation-node");
|
||||
GlobalEmitter.off("toggle-orbit");
|
||||
GlobalEmitter.off("toggle-falling-skies");
|
||||
GlobalEmitter.off("toggle-snake");
|
||||
GlobalEmitter.off("toggle-pong");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.api;
|
||||
GlobalEmitter.off("sync-propagation-node");
|
||||
GlobalEmitter.off("toggle-orbit");
|
||||
GlobalEmitter.off("toggle-falling-skies");
|
||||
GlobalEmitter.off("toggle-snake");
|
||||
GlobalEmitter.off("toggle-pong");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mountCommandPalette = () => {
|
||||
const mountCommandPalette = (route = { name: "messages" }) => {
|
||||
return mount(CommandPalette, {
|
||||
global: {
|
||||
mocks: {
|
||||
$t: (key) => key,
|
||||
$router: routerMock,
|
||||
$route: route,
|
||||
},
|
||||
stubs: {
|
||||
MaterialDesignIcon: { template: '<div class="mdi"></div>' },
|
||||
@@ -248,7 +255,7 @@ describe("CommandPalette.vue", () => {
|
||||
});
|
||||
|
||||
it("emits toggle-orbit event when orbit action is executed", async () => {
|
||||
const wrapper = mountCommandPalette();
|
||||
const wrapper = mountCommandPalette({ name: "network-visualiser" });
|
||||
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
|
||||
wrapper.vm.isOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
@@ -260,6 +267,45 @@ describe("CommandPalette.vue", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("emits toggle-falling-skies when falling skies action is executed", async () => {
|
||||
const wrapper = mountCommandPalette({ name: "network-visualiser" });
|
||||
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
|
||||
wrapper.vm.isOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-falling-skies");
|
||||
if (result) {
|
||||
wrapper.vm.executeResult(result);
|
||||
expect(emitSpy).toHaveBeenCalledWith("toggle-falling-skies");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits toggle-snake when mesh snake action is executed", async () => {
|
||||
const wrapper = mountCommandPalette({ name: "network-visualiser" });
|
||||
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
|
||||
wrapper.vm.isOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-snake");
|
||||
if (result) {
|
||||
wrapper.vm.executeResult(result);
|
||||
expect(emitSpy).toHaveBeenCalledWith("toggle-snake");
|
||||
}
|
||||
});
|
||||
|
||||
it("emits toggle-pong when mesh pong action is executed", async () => {
|
||||
const wrapper = mountCommandPalette({ name: "network-visualiser" });
|
||||
const emitSpy = vi.spyOn(GlobalEmitter, "emit");
|
||||
wrapper.vm.isOpen = true;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const result = wrapper.vm.filteredResults.find((r) => r.action === "toggle-pong");
|
||||
if (result) {
|
||||
wrapper.vm.executeResult(result);
|
||||
expect(emitSpy).toHaveBeenCalledWith("toggle-pong");
|
||||
}
|
||||
});
|
||||
|
||||
it("closes when ESC key is pressed", async () => {
|
||||
const wrapper = mountCommandPalette();
|
||||
wrapper.vm.isOpen = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import ContactsPage from "@/components/contacts/ContactsPage.vue";
|
||||
import WebSocketConnection from "@/js/WebSocketConnection";
|
||||
@@ -40,14 +40,17 @@ describe("ContactsPage.vue", () => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/telephone/contacts") {
|
||||
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
|
||||
}
|
||||
if (url === "/api/v1/telephone/contacts/export") {
|
||||
return Promise.resolve({ data: { contacts: [] } });
|
||||
}
|
||||
if (url.startsWith("/api/v1/telephone/contacts/check/")) {
|
||||
return Promise.resolve({ data: {} });
|
||||
return Promise.resolve({ data: { is_contact: false, contact: null } });
|
||||
}
|
||||
if (
|
||||
url === "/api/v1/telephone/contacts" ||
|
||||
(typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
|
||||
) {
|
||||
return Promise.resolve({ data: { contacts: [], total_count: 0 } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
@@ -145,4 +148,42 @@ describe("ContactsPage.vue", () => {
|
||||
expect(html).toContain("contacts.export_contacts");
|
||||
expect(html).toContain("contacts.import_contacts");
|
||||
});
|
||||
|
||||
it("getContacts maps total_count from telephone contacts API", async () => {
|
||||
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" ||
|
||||
(typeof url === "string" && url.startsWith("/api/v1/telephone/contacts?"))
|
||||
) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
contacts: [{ id: 1, name: "One", remote_identity_hash: "c".repeat(32) }],
|
||||
total_count: 42,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url === "/api/v1/telephone/contacts/export") {
|
||||
return Promise.resolve({ data: { contacts: [] } });
|
||||
}
|
||||
if (url.startsWith("/api/v1/telephone/contacts/check/")) {
|
||||
return Promise.resolve({ data: { is_contact: false, contact: null } });
|
||||
}
|
||||
return Promise.resolve({ data: {} });
|
||||
});
|
||||
const wrapper = mountPage();
|
||||
await flushPromises();
|
||||
expect(wrapper.vm.totalContactsCount).toBe(42);
|
||||
expect(wrapper.vm.contacts).toHaveLength(1);
|
||||
expect(wrapper.vm.contacts[0].name).toBe("One");
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ describe("ConversationViewer.vue", () => {
|
||||
let axiosMock;
|
||||
|
||||
beforeEach(() => {
|
||||
GlobalState.config.theme = "light";
|
||||
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
|
||||
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
|
||||
WebSocketConnection.connect();
|
||||
@@ -526,6 +527,29 @@ describe("ConversationViewer.vue", () => {
|
||||
expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("");
|
||||
});
|
||||
|
||||
it("uses dark neutral waiting bubble when pathfinding in dark theme with default gray", () => {
|
||||
GlobalState.config.theme = "dark";
|
||||
GlobalState.config.message_waiting_bubble_color = "#e5e7eb";
|
||||
const wrapper = mountConversationViewer();
|
||||
const chatItem = {
|
||||
type: "lxmf_message",
|
||||
is_outbound: true,
|
||||
lxmf_message: {
|
||||
hash: "h-wait-dark",
|
||||
state: "sending",
|
||||
content: "hi",
|
||||
destination_hash: "test-hash",
|
||||
source_hash: "my-hash",
|
||||
fields: {},
|
||||
_pendingPathfinding: true,
|
||||
},
|
||||
};
|
||||
expect(wrapper.vm.bubbleStyles(chatItem)).toMatchObject({
|
||||
"background-color": "#3f3f46",
|
||||
color: "#ffffff",
|
||||
});
|
||||
});
|
||||
|
||||
it("marks inbound messages with markdown-content--inbound for link styling", async () => {
|
||||
GlobalState.config.message_outbound_bubble_color = "#4f46e5";
|
||||
const wrapper = mountConversationViewer();
|
||||
@@ -803,4 +827,705 @@ describe("ConversationViewer.vue", () => {
|
||||
expect(drafts["a".repeat(32)]).toBe("save on leave");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message visibility and empty bubble prevention", () => {
|
||||
const makeChatItem = (overrides = {}) => ({
|
||||
type: "lxmf_message",
|
||||
is_outbound: false,
|
||||
lxmf_message: {
|
||||
hash: `hash-${Math.random().toString(36).slice(2, 10)}`,
|
||||
state: "delivered",
|
||||
content: "",
|
||||
destination_hash: "my-hash",
|
||||
source_hash: "test-hash",
|
||||
fields: {},
|
||||
timestamp: 1700000000,
|
||||
created_at: "2023-11-14T22:13:20.000Z",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
describe("hasRenderableContent", () => {
|
||||
it("returns true for message with text content", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "hello", fields: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for whitespace-padded text", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: " hi ", fields: {} })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for empty string content and no fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for whitespace-only content and no fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: " ", fields: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null content and no fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: null, fields: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined content and no fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ fields: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for undefined fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "" })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null fields", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for image field", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { image: { image_type: "png" } } })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for audio field", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { audio: { audio_mode: 0x10 } } })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true for file attachments", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { file_attachments: [{ file_name: "doc.pdf" }] },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for telemetry", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for telemetry stream", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { telemetry_stream: [{}] } })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for location request command", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { commands: [{ "0x01": true }] },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for location request via string key '1'", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { commands: [{ 1: true }] },
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for commands with no location request", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { commands: [{ "0x99": true }] },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty commands array", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(wrapper.vm.hasRenderableContent({ content: "", fields: { commands: [] } })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for unknown/unsupported fields only", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
expect(
|
||||
wrapper.vm.hasRenderableContent({
|
||||
content: "",
|
||||
fields: { some_future_field: { data: 42 }, another_unknown: "value" },
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isImageOnlyMessage", () => {
|
||||
it("returns true for image with no text", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({ fields: { image: { image_type: "png" } } });
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for image with null content", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({ content: null, fields: { image: { image_type: "jpg" } } });
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for image with whitespace-only content", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({ content: " ", fields: { image: { image_type: "png" } } });
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for image with auto-generated filename caption", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "photo_2024.jpg",
|
||||
fields: { image: { image_type: "jpg" } },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for image with real text caption", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "Look at this sunset!",
|
||||
fields: { image: { image_type: "jpg" } },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image with reply", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
reply_to_hash: "abc123",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image + audio", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
fields: { image: { image_type: "png" }, audio: { audio_mode: 0x10 } },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image + file attachments", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
fields: {
|
||||
image: { image_type: "png" },
|
||||
file_attachments: [{ file_name: "data.csv" }],
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image + telemetry", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
fields: {
|
||||
image: { image_type: "png" },
|
||||
telemetry: { location: { latitude: 1, longitude: 2 } },
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image + telemetry stream", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
fields: { image: { image_type: "png" }, telemetry_stream: [{}] },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for image + location request command", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
fields: { image: { image_type: "png" }, commands: [{ "0x01": true }] },
|
||||
});
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no image", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({ content: "just text", fields: {} });
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for outbound image-only", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
source_hash: "my-hash",
|
||||
destination_hash: "test-hash",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
item.is_outbound = true;
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedPeerChatItems filtering", () => {
|
||||
it("filters out messages with no renderable content", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({ content: "visible" }),
|
||||
makeChatItem({ content: "", fields: {} }),
|
||||
makeChatItem({ content: "", fields: { some_unknown: true } }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
expect(wrapper.vm.selectedPeerChatItems[0].lxmf_message.content).toBe("visible");
|
||||
});
|
||||
|
||||
it("keeps image-only messages visible", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [makeChatItem({ fields: { image: { image_type: "png" } } })];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps audio-only messages visible", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [makeChatItem({ fields: { audio: { audio_mode: 0x10 } } })];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps telemetry messages visible when showTelemetryInChat is true", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.showTelemetryInChat = true;
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("filters out reactions", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [makeChatItem({ content: "reaction", is_reaction: true })];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters out empty messages even if state is delivered", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({ content: "", fields: {}, state: "delivered" }),
|
||||
makeChatItem({ content: null, fields: {}, state: "delivered" }),
|
||||
makeChatItem({ content: " ", fields: {}, state: "delivered" }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters out empty outbound messages", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "",
|
||||
fields: {},
|
||||
source_hash: "my-hash",
|
||||
destination_hash: "test-hash",
|
||||
});
|
||||
item.is_outbound = true;
|
||||
wrapper.vm.chatItems = [item];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps file-only messages", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
fields: { file_attachments: [{ file_name: "readme.txt" }] },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles mixed visible and invisible messages correctly", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({ content: "text message" }),
|
||||
makeChatItem({ content: "", fields: {} }),
|
||||
makeChatItem({ fields: { image: { image_type: "png" } } }),
|
||||
makeChatItem({ content: "", fields: { weird_field: true } }),
|
||||
makeChatItem({ fields: { audio: { audio_mode: 0x10 } } }),
|
||||
makeChatItem({ content: "", fields: {} }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image strip grouping", () => {
|
||||
it("groups 2+ consecutive image-only messages from same sender into imageGroup", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({ hash: "img1", fields: { image: { image_type: "png" } } }),
|
||||
makeChatItem({ hash: "img2", fields: { image: { image_type: "jpg" } } }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].type).toBe("imageGroup");
|
||||
expect(groups[0].items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not group a single image-only message", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [makeChatItem({ hash: "solo-img", fields: { image: { image_type: "png" } } })];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].type).toBe("single");
|
||||
});
|
||||
|
||||
it("does not merge images with real captions into strip", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
hash: "captioned1",
|
||||
content: "Beautiful landscape",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
makeChatItem({
|
||||
hash: "captioned2",
|
||||
content: "Another beautiful shot",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.every((g) => g.type === "single")).toBe(true);
|
||||
});
|
||||
|
||||
it("groups images with filename captions into strip", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
hash: "fn1",
|
||||
content: "photo_001.jpg",
|
||||
fields: { image: { image_type: "jpg" } },
|
||||
}),
|
||||
makeChatItem({
|
||||
hash: "fn2",
|
||||
content: "photo_002.png",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].type).toBe("imageGroup");
|
||||
});
|
||||
|
||||
it("does not merge images from different senders", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const inbound = makeChatItem({
|
||||
hash: "in-img",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
inbound.is_outbound = false;
|
||||
const outbound = makeChatItem({
|
||||
hash: "out-img",
|
||||
source_hash: "my-hash",
|
||||
destination_hash: "test-hash",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
outbound.is_outbound = true;
|
||||
wrapper.vm.chatItems = [inbound, outbound];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.every((g) => g.type === "single")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not merge failed images into strip", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
hash: "fail-img",
|
||||
state: "failed",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
makeChatItem({
|
||||
hash: "ok-img",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.every((g) => g.type === "single")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not merge spam images into strip", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
hash: "spam-img",
|
||||
is_spam: true,
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
makeChatItem({ hash: "ok-img2", fields: { image: { image_type: "png" } } }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not merge image with reply into strip", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
hash: "reply-img",
|
||||
reply_to_hash: "some-hash",
|
||||
fields: { image: { image_type: "png" } },
|
||||
}),
|
||||
makeChatItem({ hash: "plain-img", fields: { image: { image_type: "png" } } }),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("caps image strip at 12 images", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const items = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
items.push(makeChatItem({ hash: `strip-${i}`, fields: { image: { image_type: "png" } } }));
|
||||
}
|
||||
wrapper.vm.chatItems = items;
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
const groups = wrapper.vm.selectedPeerChatDisplayGroups;
|
||||
const imgGroup = groups.find((g) => g.type === "imageGroup");
|
||||
expect(imgGroup).toBeDefined();
|
||||
expect(imgGroup.items.length).toBeLessThanOrEqual(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLikelyMultiImagePlaceholderCaption", () => {
|
||||
let wrapper;
|
||||
beforeEach(() => {
|
||||
wrapper = mountConversationViewer();
|
||||
});
|
||||
|
||||
it("recognizes standard image filenames", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("IMG_20240101.png")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("screenshot.webp")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("image.heic")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("cat.gif")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-image filenames", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("document.pdf")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("video.mp4")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("archive.zip")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects normal text messages", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("hello world")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("check out this image")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("I sent you a .png file")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects multiline text", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpg\nmore text")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects text with path separators", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("/home/user/photo.jpg")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("C:\\Users\\photo.jpg")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects text with special characters", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("<script>photo.jpg")).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("[photo].jpg")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects null or empty", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption(null)).toBe(false);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects very long strings", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("a".repeat(250) + ".png")).toBe(false);
|
||||
});
|
||||
|
||||
it("handles edge case extensions", () => {
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.jpeg")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.JPEG")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.PNG")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.avif")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.svg")).toBe(true);
|
||||
expect(wrapper.vm.isLikelyMultiImagePlaceholderCaption("photo.bmp")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldHideAutoImageCaption", () => {
|
||||
it("hides filename caption on image message", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "photo_2024.jpg",
|
||||
fields: { image: { image_type: "jpg" } },
|
||||
});
|
||||
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not hide real text on image message", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "Here is the picture from yesterday",
|
||||
fields: { image: { image_type: "jpg" } },
|
||||
});
|
||||
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not hide anything on non-image message", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({ content: "photo.jpg", fields: {} });
|
||||
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not hide empty content", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
expect(wrapper.vm.shouldHideAutoImageCaption(item)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases with multiple field combinations", () => {
|
||||
it("image + text shows bubble (not image-only)", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "Check this out",
|
||||
fields: { image: { image_type: "png" } },
|
||||
});
|
||||
wrapper.vm.chatItems = [item];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1);
|
||||
expect(wrapper.vm.isImageOnlyMessage(wrapper.vm.selectedPeerChatItems[0])).toBe(false);
|
||||
});
|
||||
|
||||
it("audio + text renders and is not image-only", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "Listen to this",
|
||||
fields: { audio: { audio_mode: 0x10 } },
|
||||
});
|
||||
expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("file attachment with no text still shows", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const msg = { content: "", fields: { file_attachments: [{ file_name: "a.zip" }] } };
|
||||
expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("telemetry with text still shows", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const msg = {
|
||||
content: "My location",
|
||||
fields: { telemetry: { location: { latitude: 0, longitude: 0 } } },
|
||||
};
|
||||
expect(wrapper.vm.hasRenderableContent(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it("image + audio + text is renderable but not image-only", () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
const item = makeChatItem({
|
||||
content: "multimedia",
|
||||
fields: {
|
||||
image: { image_type: "png" },
|
||||
audio: { audio_mode: 0x10 },
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.hasRenderableContent(item.lxmf_message)).toBe(true);
|
||||
expect(wrapper.vm.isImageOnlyMessage(item)).toBe(false);
|
||||
});
|
||||
|
||||
it("message with only unknown future fields is hidden", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
content: "",
|
||||
fields: { future_feature: { data: "something" } },
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("message with multiple unknown fields is hidden", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [
|
||||
makeChatItem({
|
||||
content: "",
|
||||
fields: {
|
||||
sticker: { pack: "animals", id: 3 },
|
||||
icon_appearance: { theme: "dark" },
|
||||
},
|
||||
}),
|
||||
];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("message with empty fields object is hidden", async () => {
|
||||
const wrapper = mountConversationViewer();
|
||||
wrapper.vm.chatItems = [makeChatItem({ content: "", fields: {} })];
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.vm.selectedPeerChatItems).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,48 @@ describe("MessagesPage.vue", () => {
|
||||
expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/lxmf/conversations", expect.any(Object));
|
||||
});
|
||||
|
||||
it("debounces conversation search and sends search param to conversations API", async () => {
|
||||
vi.useFakeTimers();
|
||||
axiosMock.isCancel = vi.fn(() => false);
|
||||
const wrapper = mountMessagesPage();
|
||||
await wrapper.vm.$nextTick();
|
||||
axiosMock.get.mockClear();
|
||||
|
||||
wrapper.vm.onConversationSearchChanged("findme");
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations")).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const convCalls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/lxmf/conversations");
|
||||
expect(convCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(convCalls[convCalls.length - 1][1].params.search).toBe("findme");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("debounces peers search and sends search param to announces API", async () => {
|
||||
vi.useFakeTimers();
|
||||
axiosMock.isCancel = vi.fn(() => false);
|
||||
const wrapper = mountMessagesPage();
|
||||
await wrapper.vm.$nextTick();
|
||||
axiosMock.get.mockClear();
|
||||
|
||||
wrapper.vm.onPeersSearchChanged("peerq");
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
expect(
|
||||
axiosMock.get.mock.calls.filter(
|
||||
(c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
|
||||
)
|
||||
).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const ann = axiosMock.get.mock.calls.filter(
|
||||
(c) => c[0] === "/api/v1/announces" && c[1]?.params?.aspect === "lxmf.delivery"
|
||||
);
|
||||
expect(ann.length).toBeGreaterThanOrEqual(1);
|
||||
expect(ann[ann.length - 1][1].params.search).toBe("peerq");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("opens ingest paper message modal", async () => {
|
||||
const wrapper = mountMessagesPage();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
@@ -376,4 +376,50 @@ describe("NetworkVisualiser.vue", () => {
|
||||
// After unmount, the cache should be empty or the reference should be cleared
|
||||
expect(Object.keys(cacheRef).length).toBe(0);
|
||||
});
|
||||
|
||||
it("does not add offline interfaces when showDisabledInterfaces is false", async () => {
|
||||
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
|
||||
const wrapper = mountVisualiser();
|
||||
wrapper.vm.network = {
|
||||
getPositions: vi.fn().mockReturnValue({}),
|
||||
setOptions: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
|
||||
wrapper.vm.interfaces = [
|
||||
{ name: "eth_up", status: true, bitrate: 1000, txb: 0, rxb: 0 },
|
||||
{ name: "eth_down", status: false, bitrate: 0, txb: 0, rxb: 0 },
|
||||
];
|
||||
wrapper.vm.showDisabledInterfaces = false;
|
||||
await wrapper.vm.processVisualization();
|
||||
expect(wrapper.vm.nodes.getIds()).toContain("eth_up");
|
||||
expect(wrapper.vm.nodes.getIds()).not.toContain("eth_down");
|
||||
});
|
||||
|
||||
it("keeps node positions from getPositions on subsequent layout passes", async () => {
|
||||
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
|
||||
const wrapper = mountVisualiser();
|
||||
const getPositions = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({})
|
||||
.mockReturnValue({
|
||||
me: { x: 0, y: 0 },
|
||||
eth0: { x: 301, y: 404 },
|
||||
});
|
||||
wrapper.vm.network = {
|
||||
getPositions,
|
||||
setOptions: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
|
||||
wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
|
||||
await wrapper.vm.processVisualization();
|
||||
await wrapper.vm.processVisualization();
|
||||
const n = wrapper.vm.nodes.get("eth0");
|
||||
expect(n.x).toBe(301);
|
||||
expect(n.y).toBe(404);
|
||||
expect(getPositions.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
@@ -63,6 +63,23 @@ describe("NomadNetworkPage.vue", () => {
|
||||
expect(wrapper.text()).toContain("nomadnet.no_active_node");
|
||||
});
|
||||
|
||||
it("debounces node search and passes search param to announces API", async () => {
|
||||
vi.useFakeTimers();
|
||||
axiosMock.isCancel = vi.fn(() => false);
|
||||
const wrapper = mountNomadNetworkPage();
|
||||
await wrapper.vm.$nextTick();
|
||||
axiosMock.get.mockClear();
|
||||
|
||||
wrapper.vm.onNodesSearchChanged("nodequery");
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
const calls = axiosMock.get.mock.calls.filter((c) => c[0] === "/api/v1/announces");
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1);
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last[1].params.aspect).toBe("nomadnetwork.node");
|
||||
expect(last[1].params.search).toBe("nodequery");
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads node when destinationHash prop is provided", async () => {
|
||||
const destHash = "0123456789abcdef0123456789abcdef";
|
||||
axiosMock.get.mockImplementation((url) => {
|
||||
|
||||
Reference in new issue
Block a user