From 66412c74389b272bbd7ced2f5071396e5031a59f Mon Sep 17 00:00:00 2001 From: Ivan Date: Tue, 14 Apr 2026 19:36:10 -0500 Subject: [PATCH] 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. --- cx_setup.py | 2 + scripts/build_community_interfaces_json.py | 3 +- scripts/ci/slsa-predicate.py | 4 +- scripts/move_wheels.py | 5 +- tests/backend/memory_benchmarks.py | 3 +- tests/backend/test_access_attempts_dao.py | 8 +- .../test_access_attempts_enforcement.py | 5 +- tests/backend/test_announce_fuzzing.py | 7 +- tests/backend/test_announce_limits.py | 28 +- .../backend/test_announce_manager_extended.py | 11 +- .../backend/test_archiver_manager_extended.py | 6 +- .../test_community_interfaces_directory.py | 3 +- tests/backend/test_concurrency_stress.py | 11 +- tests/backend/test_dao_fuzzing.py | 17 +- tests/backend/test_database_evolution.py | 17 +- tests/backend/test_incoming_call_policy.py | 8 +- tests/backend/test_integrity_extensive.py | 7 +- tests/backend/test_lxmf_communication.py | 44 +- tests/backend/test_meshchat_coverage.py | 33 +- tests/backend/test_message_dao_extended.py | 3 +- .../backend/test_message_handler_extended.py | 5 +- .../backend/test_nomadnet_downloader_boost.py | 10 +- .../test_package_version_resolution.py | 4 +- tests/backend/test_property_based.py | 30 +- tests/backend/test_rncp_handler_extended.py | 5 +- tests/backend/test_search_integration.py | 91 +++ tests/backend/test_security_fuzzing.py | 16 +- .../test_telephone_api_json_contracts.py | 24 +- tests/backend/test_telephone_manager_boost.py | 8 +- .../test_translator_handler_extended.py | 5 +- tests/backend/test_voicemail_manager_boost.py | 3 +- tests/frontend/CallPage.test.js | 128 +++- tests/frontend/CommandPalette.test.js | 50 +- tests/frontend/ContactsPage.test.js | 51 +- tests/frontend/ConversationViewer.test.js | 725 ++++++++++++++++++ tests/frontend/MessagesPage.test.js | 42 + tests/frontend/NetworkVisualiser.test.js | 46 ++ tests/frontend/NomadNetworkPage.test.js | 17 + 38 files changed, 1384 insertions(+), 101 deletions(-) create mode 100644 tests/backend/test_search_integration.py diff --git a/cx_setup.py b/cx_setup.py index f305067..76aea6e 100644 --- a/cx_setup.py +++ b/cx_setup.py @@ -34,6 +34,8 @@ packages = [ "RNS.Interfaces", "LXMF", "LXST", + "lxmfy", + "websockets", "pycparser", "cffi", "ply", diff --git a/scripts/build_community_interfaces_json.py b/scripts/build_community_interfaces_json.py index 4d9a2b0..5132745 100644 --- a/scripts/build_community_interfaces_json.py +++ b/scripts/build_community_interfaces_json.py @@ -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 diff --git a/scripts/ci/slsa-predicate.py b/scripts/ci/slsa-predicate.py index ef1fe64..ce6c323 100644 --- a/scripts/ci/slsa-predicate.py +++ b/scripts/ci/slsa-predicate.py @@ -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: diff --git a/scripts/move_wheels.py b/scripts/move_wheels.py index 43cf3a4..1525b89 100644 --- a/scripts/move_wheels.py +++ b/scripts/move_wheels.py @@ -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 diff --git a/tests/backend/memory_benchmarks.py b/tests/backend/memory_benchmarks.py index e997e70..f50a54d 100644 --- a/tests/backend/memory_benchmarks.py +++ b/tests/backend/memory_benchmarks.py @@ -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( diff --git a/tests/backend/test_access_attempts_dao.py b/tests/backend/test_access_attempts_dao.py index 0007832..0a72778 100644 --- a/tests/backend/test_access_attempts_dao.py +++ b/tests/backend/test_access_attempts_dao.py @@ -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) diff --git a/tests/backend/test_access_attempts_enforcement.py b/tests/backend/test_access_attempts_enforcement.py index 8d9612a..7cced2f 100644 --- a/tests/backend/test_access_attempts_enforcement.py +++ b/tests/backend/test_access_attempts_enforcement.py @@ -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: diff --git a/tests/backend/test_announce_fuzzing.py b/tests/backend/test_announce_fuzzing.py index 45b505d..beae72d 100644 --- a/tests/backend/test_announce_fuzzing.py +++ b/tests/backend/test_announce_fuzzing.py @@ -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() diff --git a/tests/backend/test_announce_limits.py b/tests/backend/test_announce_limits.py index ca3f9b7..caafb8e 100644 --- a/tests/backend/test_announce_limits.py +++ b/tests/backend/test_announce_limits.py @@ -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() diff --git a/tests/backend/test_announce_manager_extended.py b/tests/backend/test_announce_manager_extended.py index ffab9fc..53a715c 100644 --- a/tests/backend/test_announce_manager_extended.py +++ b/tests/backend/test_announce_manager_extended.py @@ -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 diff --git a/tests/backend/test_archiver_manager_extended.py b/tests/backend/test_archiver_manager_extended.py index a460ba2..fdaa2f5 100644 --- a/tests/backend/test_archiver_manager_extended.py +++ b/tests/backend/test_archiver_manager_extended.py @@ -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,), ) diff --git a/tests/backend/test_community_interfaces_directory.py b/tests/backend/test_community_interfaces_directory.py index 4fd9673..d583472 100644 --- a/tests/backend/test_community_interfaces_directory.py +++ b/tests/backend/test_community_interfaces_directory.py @@ -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", ""]), diff --git a/tests/backend/test_concurrency_stress.py b/tests/backend/test_concurrency_stress.py index cd11ba8..f7b284c 100644 --- a/tests/backend/test_concurrency_stress.py +++ b/tests/backend/test_concurrency_stress.py @@ -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", ) diff --git a/tests/backend/test_dao_fuzzing.py b/tests/backend/test_dao_fuzzing.py index c3fc270..6444241 100644 --- a/tests/backend/test_dao_fuzzing.py +++ b/tests/backend/test_dao_fuzzing.py @@ -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", diff --git a/tests/backend/test_database_evolution.py b/tests/backend/test_database_evolution.py index 21f6faa..76a640e 100644 --- a/tests/backend/test_database_evolution.py +++ b/tests/backend/test_database_evolution.py @@ -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") diff --git a/tests/backend/test_incoming_call_policy.py b/tests/backend/test_incoming_call_policy.py index eb765a5..c6853e4 100644 --- a/tests/backend/test_incoming_call_policy.py +++ b/tests/backend/test_incoming_call_policy.py @@ -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() diff --git a/tests/backend/test_integrity_extensive.py b/tests/backend/test_integrity_extensive.py index 3f9c26f..0a85548 100644 --- a/tests/backend/test_integrity_extensive.py +++ b/tests/backend/test_integrity_extensive.py @@ -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): diff --git a/tests/backend/test_lxmf_communication.py b/tests/backend/test_lxmf_communication.py index bebfab1..a160898 100644 --- a/tests/backend/test_lxmf_communication.py +++ b/tests/backend/test_lxmf_communication.py @@ -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): diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py index ecaffa8..d4b3b3b 100644 --- a/tests/backend/test_meshchat_coverage.py +++ b/tests/backend/test_meshchat_coverage.py @@ -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 diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py index 5c6b97f..e2a9c60 100644 --- a/tests/backend/test_message_dao_extended.py +++ b/tests/backend/test_message_dao_extended.py @@ -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",), ) diff --git a/tests/backend/test_message_handler_extended.py b/tests/backend/test_message_handler_extended.py index b11b1f0..24f7a08 100644 --- a/tests/backend/test_message_handler_extended.py +++ b/tests/backend/test_message_handler_extended.py @@ -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 diff --git a/tests/backend/test_nomadnet_downloader_boost.py b/tests/backend/test_nomadnet_downloader_boost.py index 6898bbd..2f7af2d 100644 --- a/tests/backend/test_nomadnet_downloader_boost.py +++ b/tests/backend/test_nomadnet_downloader_boost.py @@ -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) diff --git a/tests/backend/test_package_version_resolution.py b/tests/backend/test_package_version_resolution.py index 0455127..f9b48ff 100644 --- a/tests/backend/test_package_version_resolution.py +++ b/tests/backend/test_package_version_resolution.py @@ -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" diff --git a/tests/backend/test_property_based.py b/tests/backend/test_property_based.py index 3ce9532..58a0102 100644 --- a/tests/backend/test_property_based.py +++ b/tests/backend/test_property_based.py @@ -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) diff --git a/tests/backend/test_rncp_handler_extended.py b/tests/backend/test_rncp_handler_extended.py index 6f56438..440f990 100644 --- a/tests/backend/test_rncp_handler_extended.py +++ b/tests/backend/test_rncp_handler_extended.py @@ -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() diff --git a/tests/backend/test_search_integration.py b/tests/backend/test_search_integration.py new file mode 100644 index 0000000..3009503 --- /dev/null +++ b/tests/backend/test_search_integration.py @@ -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 diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py index e8ca4cd..ac0a23c 100644 --- a/tests/backend/test_security_fuzzing.py +++ b/tests/backend/test_security_fuzzing.py @@ -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 diff --git a/tests/backend/test_telephone_api_json_contracts.py b/tests/backend/test_telephone_api_json_contracts.py index 9f0a582..4e821ed 100644 --- a/tests/backend/test_telephone_api_json_contracts.py +++ b/tests/backend/test_telephone_api_json_contracts.py @@ -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, diff --git a/tests/backend/test_telephone_manager_boost.py b/tests/backend/test_telephone_manager_boost.py index cfb6e54..d008ee8 100644 --- a/tests/backend/test_telephone_manager_boost.py +++ b/tests/backend/test_telephone_manager_boost.py @@ -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) diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py index 8da19e6..fb3a423 100644 --- a/tests/backend/test_translator_handler_extended.py +++ b/tests/backend/test_translator_handler_extended.py @@ -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" diff --git a/tests/backend/test_voicemail_manager_boost.py b/tests/backend/test_voicemail_manager_boost.py index 22d8dae..969ae09 100644 --- a/tests/backend/test_voicemail_manager_boost.py +++ b/tests/backend/test_voicemail_manager_boost.py @@ -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 diff --git a/tests/frontend/CallPage.test.js b/tests/frontend/CallPage.test.js index ba2e749..46810f0 100644 --- a/tests/frontend/CallPage.test.js +++ b/tests/frontend/CallPage.test.js @@ -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); + }); }); diff --git a/tests/frontend/CommandPalette.test.js b/tests/frontend/CommandPalette.test.js index 465d3db..55e011d 100644 --- a/tests/frontend/CommandPalette.test.js +++ b/tests/frontend/CommandPalette.test.js @@ -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: '
' }, @@ -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; diff --git a/tests/frontend/ContactsPage.test.js b/tests/frontend/ContactsPage.test.js index e7f1e0e..dde3e27 100644 --- a/tests/frontend/ContactsPage.test.js +++ b/tests/frontend/ContactsPage.test.js @@ -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"); + }); }); diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index 2cf5103..67f1983 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -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("