From d3ca433a7c825bd5eb517c3c2b0975f06c9c2390 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 27 Apr 2026 11:16:52 -0500 Subject: [PATCH] feat(tests): update fe/be tests and contract api routes --- tests/backend/conftest.py | 7 + tests/backend/fixtures/http_api_routes.json | 4 + tests/backend/test_announce_limits.py | 10 + .../backend/test_announce_manager_extended.py | 51 +++ tests/backend/test_announce_store_config.py | 22 ++ tests/backend/test_community_interfaces.py | 94 ++++- tests/backend/test_csp_logic.py | 66 ++++ tests/backend/test_interface_discovery.py | 125 ++++++ tests/backend/test_local_message_retention.py | 219 +++++++++++ tests/backend/test_lxmf_propagation_full.py | 2 + tests/backend/test_lxmf_utils_extended.py | 30 ++ tests/backend/test_meshchat_coverage.py | 8 +- tests/backend/test_message_dao_extended.py | 11 + .../backend/test_message_sending_failures.py | 9 +- tests/backend/test_reticulum_pathfinding.py | 358 ++++++++++++++++++ .../test_reticulum_pathfinding_http.py | 66 ++++ tests/backend/test_rnode_download_firmware.py | 91 ++++- tests/backend/test_startup.py | 3 +- tests/backend/test_startup_advanced.py | 6 +- .../test_translator_argos_integration.py | 10 +- .../test_translator_config_migration.py | 18 + tests/backend/test_translator_handler.py | 11 +- .../test_translator_handler_extended.py | 42 +- tests/frontend/AboutPage.test.js | 106 +++++- .../AppSidebarIdentityAnnounce.test.js | 235 ++++++++++++ tests/frontend/ConversationViewer.test.js | 172 ++++++++- .../ConversationViewerButtons.test.js | 103 ++++- tests/frontend/Interface.test.js | 4 +- tests/frontend/RNodeFlasherPage.test.js | 177 ++++++++- .../SettingsPage.config-persistence.test.js | 20 +- tests/frontend/TranslatorPage.test.js | 46 ++- tests/frontend/clipboardUtils.test.js | 81 ++++ .../frontend/fixtures/settingsPageTestApi.js | 8 +- tests/frontend/localMessageRetention.test.js | 21 + tests/frontend/lxmfReactions.test.js | 21 + tests/frontend/messageDisplayLimits.test.js | 23 ++ tests/frontend/messageListVirtual.test.js | 12 + .../frontend/messageTimestampGrouping.test.js | 111 ++++++ tests/frontend/reticulumPathfinding.test.js | 81 ++++ tests/frontend/settingsSearchUtils.test.js | 52 +++ 40 files changed, 2462 insertions(+), 74 deletions(-) create mode 100644 tests/backend/test_announce_store_config.py create mode 100644 tests/backend/test_local_message_retention.py create mode 100644 tests/backend/test_reticulum_pathfinding.py create mode 100644 tests/backend/test_reticulum_pathfinding_http.py create mode 100644 tests/backend/test_translator_config_migration.py create mode 100644 tests/frontend/AppSidebarIdentityAnnounce.test.js create mode 100644 tests/frontend/clipboardUtils.test.js create mode 100644 tests/frontend/localMessageRetention.test.js create mode 100644 tests/frontend/messageDisplayLimits.test.js create mode 100644 tests/frontend/messageTimestampGrouping.test.js create mode 100644 tests/frontend/reticulumPathfinding.test.js create mode 100644 tests/frontend/settingsSearchUtils.test.js diff --git a/tests/backend/conftest.py b/tests/backend/conftest.py index 7f4e719..b2bbf70 100644 --- a/tests/backend/conftest.py +++ b/tests/backend/conftest.py @@ -195,6 +195,13 @@ def mock_app(db, tmp_path, temp_db): new=MagicMock(return_value=None), ), ) + stack.enter_context( + patch.object( + ReticulumMeshChat, + "local_message_retention_loop", + new=MagicMock(return_value=None), + ), + ) stack.enter_context( patch.object( ReticulumMeshChat, diff --git a/tests/backend/fixtures/http_api_routes.json b/tests/backend/fixtures/http_api_routes.json index 7048996..910999e 100644 --- a/tests/backend/fixtures/http_api_routes.json +++ b/tests/backend/fixtures/http_api_routes.json @@ -100,6 +100,10 @@ "method": "GET", "path": "/api/v1/community-interfaces" }, + { + "method": "POST", + "path": "/api/v1/community-interfaces/refresh" + }, { "method": "GET", "path": "/api/v1/comports" diff --git a/tests/backend/test_announce_limits.py b/tests/backend/test_announce_limits.py index 34ecfea..59bb9b4 100644 --- a/tests/backend/test_announce_limits.py +++ b/tests/backend/test_announce_limits.py @@ -24,6 +24,16 @@ def mock_config(): config.announce_fetch_limit_lxmf_delivery = MagicMock() config.announce_fetch_limit_nomadnetwork_node = MagicMock() config.announce_fetch_limit_lxmf_propagation = MagicMock() + for _k in ( + "announce_store_lxmf_delivery", + "announce_store_lxst_telephony", + "announce_store_nomadnetwork_node", + "announce_store_lxmf_propagation", + "announce_store_git_repositories", + ): + _m = MagicMock() + _m.get.return_value = True + setattr(config, _k, _m) return config diff --git a/tests/backend/test_announce_manager_extended.py b/tests/backend/test_announce_manager_extended.py index f33b3f2..0420e3f 100644 --- a/tests/backend/test_announce_manager_extended.py +++ b/tests/backend/test_announce_manager_extended.py @@ -44,6 +44,57 @@ def test_upsert_announce(mock_db): assert data["app_data"] == base64.b64encode(b"app_data").decode("utf-8") +def test_upsert_skips_when_store_disabled_for_aspect(mock_db): + config = MagicMock() + config.announce_store_lxmf_delivery = MagicMock() + config.announce_store_lxmf_delivery.get.return_value = False + manager = AnnounceManager(mock_db, config=config) + manager.upsert_announce( + None, + MagicMock(), + b"\x00" * 16, + "lxmf.delivery", + b"x", + None, + ) + mock_db.announces.upsert_announce.assert_not_called() + + +def test_is_storing_announce_for_aspect_respects_config(mock_db): + config = MagicMock() + config.announce_store_git_repositories = MagicMock() + config.announce_store_git_repositories.get.return_value = False + manager = AnnounceManager(mock_db, config=config) + assert manager.is_storing_announce_for_aspect("git.repositories") is False + assert ( + manager.is_storing_announce_for_aspect("git.repositories", force_store=True) + is True + ) + assert manager.is_storing_announce_for_aspect("unknown.aspect") is True + + +def test_upsert_force_store_bypasses_disabled_config(mock_db): + config = MagicMock() + config.announce_store_nomadnetwork_node = MagicMock() + config.announce_store_nomadnetwork_node.get.return_value = False + config.announce_max_stored_nomadnetwork_node = MagicMock() + config.announce_max_stored_nomadnetwork_node.get.return_value = 1000 + manager = AnnounceManager(mock_db, config=config) + idm = MagicMock() + idm.hash.hex.return_value = "a" * 64 + idm.get_public_key.return_value = b"k" + manager.upsert_announce( + None, + idm, + b"\x00" * 16, + "nomadnetwork.node", + None, + None, + force_store=True, + ) + mock_db.announces.upsert_announce.assert_called_once() + + def test_get_filtered_announces(mock_db): manager = AnnounceManager(mock_db) manager.get_filtered_announces(aspect="test", query="search", limit=10) diff --git a/tests/backend/test_announce_store_config.py b/tests/backend/test_announce_store_config.py new file mode 100644 index 0000000..1b61b87 --- /dev/null +++ b/tests/backend/test_announce_store_config.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: 0BSD + +import pytest + + +@pytest.mark.asyncio +async def test_update_config_sets_announce_store_flags(mock_app): + await mock_app.update_config( + { + "announce_store_lxmf_delivery": False, + "announce_store_lxst_telephony": True, + "announce_store_nomadnetwork_node": False, + "announce_store_lxmf_propagation": True, + "announce_store_git_repositories": False, + } + ) + c = mock_app.config + assert c.announce_store_lxmf_delivery.get() is False + assert c.announce_store_lxst_telephony.get() is True + assert c.announce_store_nomadnetwork_node.get() is False + assert c.announce_store_lxmf_propagation.get() is True + assert c.announce_store_git_repositories.get() is False diff --git a/tests/backend/test_community_interfaces.py b/tests/backend/test_community_interfaces.py index d6bb31b..81300d9 100644 --- a/tests/backend/test_community_interfaces.py +++ b/tests/backend/test_community_interfaces.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: 0BSD -from unittest.mock import MagicMock +import json +from unittest.mock import MagicMock, patch import pytest @@ -57,3 +58,94 @@ async def test_community_interfaces_static_list(): ifaces2 = await manager.get_interfaces() assert ifaces1 == ifaces2 assert all(iface.get("online") is None for iface in ifaces1) + + +@pytest.mark.asyncio +async def test_community_interfaces_cache_used_when_no_public_override(tmp_path): + cache = tmp_path / "community_interfaces_cache.json" + cache.write_text( + json.dumps( + { + "interfaces": [ + { + "name": "CacheOnly", + "type": "TCPClientInterface", + "target_host": "10.0.0.1", + "target_port": 4242, + }, + ], + }, + ), + encoding="utf-8", + ) + manager = CommunityInterfacesManager(public_override_path=None, cache_path=cache) + ifaces = await manager.get_interfaces() + assert len(ifaces) == 1 + assert ifaces[0]["name"] == "CacheOnly" + + +@pytest.mark.asyncio +async def test_community_interfaces_public_override_beats_cache(tmp_path): + public = tmp_path / "public.json" + public.write_text( + json.dumps( + { + "interfaces": [ + { + "name": "FromPublic", + "type": "TCPClientInterface", + "target_host": "10.0.0.2", + "target_port": 4242, + }, + ], + }, + ), + encoding="utf-8", + ) + cache = tmp_path / "cache.json" + cache.write_text( + json.dumps( + { + "interfaces": [ + { + "name": "FromCache", + "type": "TCPClientInterface", + "target_host": "10.0.0.3", + "target_port": 4242, + }, + ], + }, + ), + encoding="utf-8", + ) + manager = CommunityInterfacesManager( + public_override_path=str(public), + cache_path=str(cache), + ) + ifaces = await manager.get_interfaces() + assert len(ifaces) == 1 + assert ifaces[0]["name"] == "FromPublic" + + +def test_refresh_from_directory_writes_cache(tmp_path): + fake = [ + { + "name": "FromNet", + "type": "TCPClientInterface", + "target_host": "9.9.9.9", + "target_port": 4242, + }, + ] + cache = tmp_path / "community_interfaces_cache.json" + manager = CommunityInterfacesManager(public_override_path=None, cache_path=cache) + with patch( + "meshchatx.src.backend.community_interfaces_directory.build_interfaces_from_directory_url", + return_value=(fake, "https://example.test/list"), + ) as mock_build: + out = manager.refresh_from_directory() + mock_build.assert_called_once() + assert out["count"] == 1 + assert out["source"] == "https://example.test/list" + assert cache.is_file() + manager2 = CommunityInterfacesManager(public_override_path=None, cache_path=cache) + assert manager2.interfaces[0]["name"] == "FromNet" diff --git a/tests/backend/test_csp_logic.py b/tests/backend/test_csp_logic.py index 457188c..2985ace 100644 --- a/tests/backend/test_csp_logic.py +++ b/tests/backend/test_csp_logic.py @@ -72,6 +72,72 @@ async def test_csp_header_logic(mock_rns_minimal, tmp_path): assert m is not None and "blob:" in m.group(1) +@pytest.mark.asyncio +async def test_security_middleware_sets_cors_headers_on_rnode_flasher( + mock_rns_minimal, tmp_path +): + storage_dir = str(tmp_path / "storage") + config_dir = str(tmp_path / "config") + + with patch("meshchatx.meshchat.generate_ssl_certificate"): + app_instance = ReticulumMeshChat( + identity=mock_rns_minimal, + storage_dir=storage_dir, + reticulum_config_dir=config_dir, + ) + + request = MagicMock(spec=web.Request) + request.path = "/rnode-flasher/js/esptool-js@0.4.5/bundle.js" + request.app = {} + + async def mock_handler(req): + return web.Response(text="// module") + + routes = web.RouteTableDef() + _, _, security_middleware = app_instance._define_routes(routes) + + response = await security_middleware(request, mock_handler) + + assert response.headers.get("Access-Control-Allow-Origin") == "*" + assert response.headers.get("Cross-Origin-Resource-Policy") == "cross-origin" + csp = response.headers.get("Content-Security-Policy", "") + m = re.search(r"script-src([^;]+);", csp) + assert m is not None and "'unsafe-eval'" in m.group(1) + + +@pytest.mark.asyncio +async def test_security_middleware_does_not_set_cors_on_reticulum_docs( + mock_rns_minimal, tmp_path +): + storage_dir = str(tmp_path / "storage") + config_dir = str(tmp_path / "config") + + with patch("meshchatx.meshchat.generate_ssl_certificate"): + app_instance = ReticulumMeshChat( + identity=mock_rns_minimal, + storage_dir=storage_dir, + reticulum_config_dir=config_dir, + ) + + request = MagicMock(spec=web.Request) + request.path = "/reticulum-docs/manual/index.html" + request.app = {} + + async def mock_handler(req): + return web.Response(text="") + + routes = web.RouteTableDef() + _, _, security_middleware = app_instance._define_routes(routes) + + response = await security_middleware(request, mock_handler) + + assert response.headers.get("Access-Control-Allow-Origin") is None + assert response.headers.get("Cross-Origin-Resource-Policy") is None + csp = response.headers.get("Content-Security-Policy", "") + m = re.search(r"script-src([^;]+);", csp) + assert m is not None and "'unsafe-eval'" not in m.group(1) + + @pytest.mark.asyncio async def test_config_update_csp(mock_rns_minimal, tmp_path): storage_dir = str(tmp_path / "storage") diff --git a/tests/backend/test_interface_discovery.py b/tests/backend/test_interface_discovery.py index 4a7bb69..9e44694 100644 --- a/tests/backend/test_interface_discovery.py +++ b/tests/backend/test_interface_discovery.py @@ -58,6 +58,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir): "interface_discovery_blacklist": "tcp-bad,*:9999", "required_discovery_value": "16", "autoconnect_discovered_interfaces": "2", + "default_bootstrap_only": "yes", "network_identity": "/tmp/net_id", }, "interfaces": {}, @@ -105,6 +106,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir): ) assert get_data["discovery"]["required_discovery_value"] == "16" assert get_data["discovery"]["autoconnect_discovered_interfaces"] == "2" + assert get_data["discovery"]["default_bootstrap_only"] is True assert get_data["discovery"]["network_identity"] == "/tmp/net_id" # PATCH updates and persists @@ -115,6 +117,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir): "interface_discovery_blacklist": "", "required_discovery_value": 18, "autoconnect_discovered_interfaces": 5, + "default_bootstrap_only": False, "network_identity": "/tmp/other_id", } @@ -134,6 +137,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir): assert patch_data["discovery"]["interface_discovery_blacklist"] is None assert patch_data["discovery"]["required_discovery_value"] == 18 assert patch_data["discovery"]["autoconnect_discovered_interfaces"] == 5 + assert patch_data["discovery"]["default_bootstrap_only"] is False assert patch_data["discovery"]["network_identity"] == "/tmp/other_id" assert config["reticulum"]["discover_interfaces"] is False assert "interface_discovery_sources" not in config["reticulum"] @@ -141,6 +145,7 @@ async def test_reticulum_discovery_get_and_patch(temp_dir): assert "interface_discovery_blacklist" not in config["reticulum"] assert config["reticulum"]["required_discovery_value"] == 18 assert config["reticulum"]["autoconnect_discovered_interfaces"] == 5 + assert config["reticulum"]["default_bootstrap_only"] is False assert config["reticulum"]["network_identity"] == "/tmp/other_id" assert config.write_called @@ -356,9 +361,129 @@ async def test_interface_add_includes_discovery_fields(temp_dir): assert saved["discovery_frequency"] == 915000000 assert saved["discovery_bandwidth"] == 125000 assert saved["discovery_modulation"] == "LoRa" + assert saved.get("bootstrap_only") == "yes" assert config.write_called +@pytest.mark.asyncio +async def test_interface_add_tcp_omits_bootstrap_when_default_off(temp_dir): + config = ConfigDict( + { + "reticulum": {"default_bootstrap_only": False}, + "interfaces": {}, + }, + ) + + with ( + patch("meshchatx.meshchat.generate_ssl_certificate"), + patch("RNS.Reticulum") as mock_rns, + patch("RNS.Transport"), + patch("LXMF.LXMRouter"), + ): + mock_reticulum = mock_rns.return_value + mock_reticulum.config = config + mock_reticulum.configpath = "/tmp/mock_config" + mock_reticulum.is_connected_to_shared_instance = False + mock_reticulum.transport_enabled.return_value = True + + app_instance = ReticulumMeshChat( + identity=build_identity(), + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + add_handler = await find_route_handler( + app_instance, + "/api/v1/reticulum/interfaces/add", + "POST", + ) + assert add_handler + + payload = { + "allow_overwriting_interface": False, + "name": "NoBoot", + "type": "TCPClientInterface", + "target_host": "example.com", + "target_port": "4242", + } + + class AddRequest: + @staticmethod + async def json(): + return payload + + response = await add_handler(AddRequest()) + data = json.loads(response.body) + assert "Interface has been added" in data["message"] + saved = config["interfaces"]["NoBoot"] + assert "bootstrap_only" not in saved + + +@pytest.mark.asyncio +async def test_interface_add_tcp_explicit_bootstrap_only_no(temp_dir): + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + with ( + patch("meshchatx.meshchat.generate_ssl_certificate"), + patch("RNS.Reticulum") as mock_rns, + patch("RNS.Transport"), + patch("LXMF.LXMRouter"), + ): + mock_reticulum = mock_rns.return_value + mock_reticulum.config = config + mock_reticulum.configpath = "/tmp/mock_config" + mock_reticulum.is_connected_to_shared_instance = False + mock_reticulum.transport_enabled.return_value = True + + app_instance = ReticulumMeshChat( + identity=build_identity(), + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + add_handler = await find_route_handler( + app_instance, + "/api/v1/reticulum/interfaces/add", + "POST", + ) + assert add_handler + + payload = { + "allow_overwriting_interface": False, + "name": "ExplicitNo", + "type": "TCPClientInterface", + "target_host": "example.com", + "target_port": "4242", + "bootstrap_only": False, + } + + class AddRequest: + @staticmethod + async def json(): + return payload + + response = await add_handler(AddRequest()) + data = json.loads(response.body) + assert "Interface has been added" in data["message"] + assert config["interfaces"]["ExplicitNo"]["bootstrap_only"] == "no" + + +def test_apply_bootstrap_only_to_interface(): + details = {} + ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, True) + assert details["bootstrap_only"] == "yes" + + details = {"bootstrap_only": "yes"} + ReticulumMeshChat.apply_bootstrap_only_to_interface( + details, {"bootstrap_only": False}, True + ) + assert details["bootstrap_only"] == "no" + + details = {} + ReticulumMeshChat.apply_bootstrap_only_to_interface(details, {}, False) + assert "bootstrap_only" not in details + + @pytest.mark.asyncio async def test_interface_add_discoverable_without_optional_coordinates(temp_dir): config = ConfigDict({"reticulum": {}, "interfaces": {}}) diff --git a/tests/backend/test_local_message_retention.py b/tests/backend/test_local_message_retention.py new file mode 100644 index 0000000..1ef32bd --- /dev/null +++ b/tests/backend/test_local_message_retention.py @@ -0,0 +1,219 @@ +# SPDX-License-Identifier: 0BSD + +import time +from unittest.mock import MagicMock + +import pytest + +from meshchatx.src.backend import local_message_retention as lmr +from meshchatx.src.backend.database import Database +from meshchatx.src.backend.database.provider import DatabaseProvider +from meshchatx.src.backend.database.schema import DatabaseSchema + + +def test_normalize_unit(): + assert lmr.normalize_unit("DAYS") == lmr.UNIT_DAYS + assert lmr.normalize_unit("Month") == lmr.UNIT_MONTHS + assert lmr.normalize_unit("m") == lmr.UNIT_MONTHS + assert lmr.normalize_unit(None) == lmr.UNIT_DAYS + + +def test_retention_window_seconds(): + assert lmr.retention_window_seconds(1, "days") == 86400 + assert lmr.retention_window_seconds(2, "months") == 2 * 30 * 86400 + assert lmr.retention_window_seconds(20000, "days") == lmr.MAX_VALUE_DAYS * 86400 + assert ( + lmr.retention_window_seconds(200, "months") == lmr.MAX_VALUE_MONTHS * 30 * 86400 + ) + + +def test_local_message_retention_cutoff_ts(): + now = 1_000_000.0 + c = lmr.local_message_retention_cutoff_ts(now, 1, "days") + assert c == now - 86400.0 + + +@pytest.fixture +def _db_path(tmp_path): + return str(tmp_path / "t.db") + + +def test_apply_deletes_and_prunes(_db_path): + provider = DatabaseProvider(_db_path) + DatabaseSchema(provider).initialize() + db = Database(_db_path) + now = time.time() + old_ts = now - 10 * 86400 + new_ts = now - 86400 + peer = "a" * 32 + base = { + "source_hash": peer, + "destination_hash": peer, + "peer_hash": peer, + "state": "delivered", + "progress": 1.0, + "is_incoming": 1, + "method": "ephemeral", + "delivery_attempts": 0, + "next_delivery_attempt_at": None, + "title": "t", + "content": "c", + "fields": None, + "rssi": None, + "snr": None, + "quality": None, + "is_spam": 0, + "reply_to_hash": None, + "attachments_stripped": 0, + } + db.messages.upsert_lxmf_message( + {**base, "hash": "a" * 32, "timestamp": old_ts}, + ) + db.messages.upsert_lxmf_message( + {**base, "hash": "b" * 32, "timestamp": new_ts}, + ) + db.provider.execute( + "INSERT INTO lxmf_conversation_read_state (destination_hash) VALUES (?)", + (peer,), + ) + n = lmr.apply_local_message_retention( + db.messages, + None, + value=2, + unit=lmr.UNIT_DAYS, + now=now, + ) + assert n == 1 + assert db.messages.count_lxmf_messages() == 1 + left = db.provider.fetchone( + "SELECT 1 AS ok FROM lxmf_messages WHERE hash = ?", ("b" * 32,) + ) + assert left is not None + rs = db.provider.fetchall( + "SELECT 1 AS ok FROM lxmf_conversation_read_state WHERE destination_hash = ?", + (peer,), + ) + assert len(rs) == 1 + db.close_all() + provider.close_all() + + +def test_prune_clears_read_state_when_conversation_empty(_db_path): + provider = DatabaseProvider(_db_path) + DatabaseSchema(provider).initialize() + db = Database(_db_path) + now = time.time() + peer = "c" * 32 + h = "d" * 32 + old_ts = now - 3 * 86400 + db.messages.upsert_lxmf_message( + { + "hash": h, + "source_hash": peer, + "destination_hash": peer, + "peer_hash": peer, + "state": "delivered", + "progress": 1.0, + "is_incoming": 1, + "method": "ephemeral", + "delivery_attempts": 0, + "next_delivery_attempt_at": None, + "title": "t", + "content": "c", + "fields": None, + "timestamp": old_ts, + "rssi": None, + "snr": None, + "quality": None, + "is_spam": 0, + "reply_to_hash": None, + "attachments_stripped": 0, + }, + ) + db.provider.execute( + "INSERT INTO lxmf_conversation_read_state (destination_hash) VALUES (?)", + (peer,), + ) + lmr.apply_local_message_retention( + db.messages, + None, + value=1, + unit=lmr.UNIT_DAYS, + now=now, + ) + assert db.messages.count_lxmf_messages() == 0 + assert ( + len( + db.provider.fetchall( + "SELECT 1 AS x FROM lxmf_conversation_read_state WHERE destination_hash = ?", + (peer,), + ) + ) + == 0 + ) + db.close_all() + provider.close_all() + + +async def test_config_patch_local_message_retention_keys(mock_app): + await mock_app.update_config( + { + "local_message_auto_delete_enabled": True, + "local_message_auto_delete_value": 3, + "local_message_auto_delete_unit": "months", + }, + ) + assert mock_app.config.local_message_auto_delete_enabled.get() is True + assert mock_app.config.local_message_auto_delete_value.get() == 3 + assert mock_app.config.local_message_auto_delete_unit.get() == "months" + await mock_app.update_config( + { + "local_message_auto_delete_value": 9999, + "local_message_auto_delete_unit": "months", + }, + ) + assert mock_app.config.local_message_auto_delete_value.get() == lmr.MAX_VALUE_MONTHS + + +def test_apply_calls_cancel_for_hex_hashes(_db_path): + provider = DatabaseProvider(_db_path) + DatabaseSchema(provider).initialize() + db = Database(_db_path) + now = time.time() + h = "aa" * 16 + db.messages.upsert_lxmf_message( + { + "hash": h, + "source_hash": h, + "destination_hash": h, + "peer_hash": h, + "state": "sending", + "progress": 0.0, + "is_incoming": 0, + "method": "opportunistic", + "delivery_attempts": 0, + "next_delivery_attempt_at": None, + "title": "", + "content": "x", + "fields": None, + "timestamp": now - 5 * 86400, + "rssi": None, + "snr": None, + "quality": None, + "is_spam": 0, + "reply_to_hash": None, + "attachments_stripped": 0, + }, + ) + cancel = MagicMock() + lmr.apply_local_message_retention( + db.messages, + cancel, + value=1, + unit="days", + now=now, + ) + cancel.assert_called() + assert db.messages.count_lxmf_messages() == 0 + db.close_all() + provider.close_all() diff --git a/tests/backend/test_lxmf_propagation_full.py b/tests/backend/test_lxmf_propagation_full.py index 7db609a..9147626 100644 --- a/tests/backend/test_lxmf_propagation_full.py +++ b/tests/backend/test_lxmf_propagation_full.py @@ -352,6 +352,8 @@ async def test_destination_path_returns_local_hop_zero_for_local_destinations(mo assert data["path"]["hops"] == 0 assert data["path"]["next_hop"] == local_hash assert data["path"]["next_hop_interface"] == "Local" + assert data["path_stale"] is False + assert data["path_unresponsive"] is False def test_convert_propagation_node_state_maps_all_lxmf_transfer_states(): diff --git a/tests/backend/test_lxmf_utils_extended.py b/tests/backend/test_lxmf_utils_extended.py index 24f39c7..43aca00 100644 --- a/tests/backend/test_lxmf_utils_extended.py +++ b/tests/backend/test_lxmf_utils_extended.py @@ -144,6 +144,8 @@ def test_convert_db_lxmf_message_to_dict(): "snr": 5, "quality": 2, "is_spam": 0, + "path_hops_at_send": 4, + "path_interface_at_send": "RNode Interface", "created_at": "2023-01-01 12:00:00", "updated_at": "2023-01-01 12:05:00", } @@ -152,6 +154,8 @@ def test_convert_db_lxmf_message_to_dict(): result = convert_db_lxmf_message_to_dict(db_msg, include_attachments=True) assert result["fields"]["image"]["image_bytes"] is not None assert result["created_at"].endswith("Z") + assert result["path_hops_at_send"] == 4 + assert result["path_interface_at_send"] == "RNode Interface" # Test without attachments result_no_att = convert_db_lxmf_message_to_dict(db_msg, include_attachments=False) @@ -161,6 +165,32 @@ def test_convert_db_lxmf_message_to_dict(): assert result_no_att["fields"]["file_attachments"][0]["file_size"] == len(b"file") +def test_convert_db_lxmf_message_to_dict_defaults_missing_method(): + db_msg = { + "id": 1, + "hash": "a" * 32, + "source_hash": "b" * 32, + "destination_hash": "c" * 32, + "is_incoming": 0, + "state": "sent", + "progress": 100.0, + "delivery_attempts": 0, + "next_delivery_attempt_at": None, + "title": "", + "content": "x", + "fields": "{}", + "timestamp": 1.0, + "rssi": None, + "snr": None, + "quality": None, + "is_spam": 0, + "created_at": "2023-01-01 12:00:00", + "updated_at": "2023-01-01 12:00:00", + } + result = convert_db_lxmf_message_to_dict(db_msg) + assert result["method"] == "unknown" + + def test_convert_lxmf_message_to_dict_with_reply(): mock_msg = MagicMock(spec=LXMF.LXMessage) mock_msg.hash = b"msg_hash" diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py index 68bcb5c..5ca2ebd 100644 --- a/tests/backend/test_meshchat_coverage.py +++ b/tests/backend/test_meshchat_coverage.py @@ -10,6 +10,7 @@ import pytest from meshchatx.meshchat import ReticulumMeshChat from meshchatx.src.backend.lxmf_message_fields import LxmfAudioField +from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome @pytest.fixture @@ -145,7 +146,8 @@ def test_get_config_dict_basic(mock_app): "banished_color", "message_font_size", "message_icon_size", - "translator_enabled", + "translator_argos_enabled", + "translator_libretranslate_enabled", "libretranslate_url", "desktop_open_calls_in_separate_window", "desktop_hardware_acceleration_enabled", @@ -528,7 +530,9 @@ def sendable_app(mock_app): ctx.local_lxmf_destination = MagicMock() ctx.forwarding_manager = None - mock_app._await_transport_path = AsyncMock() + mock_app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(True, "reused_valid_path", False), + ) mock_app.get_current_icon_hash = MagicMock(return_value=None) mock_app.db_upsert_lxmf_message = MagicMock() mock_app.websocket_broadcast = AsyncMock() diff --git a/tests/backend/test_message_dao_extended.py b/tests/backend/test_message_dao_extended.py index 315f054..c307091 100644 --- a/tests/backend/test_message_dao_extended.py +++ b/tests/backend/test_message_dao_extended.py @@ -56,3 +56,14 @@ def test_get_conversation_messages(message_dao, mock_provider): "SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?", ("peer1", 10, 5), ) + + +def test_set_lxmf_message_path_at_send_if_unset(message_dao, mock_provider): + message_dao.set_lxmf_message_path_at_send_if_unset("deadbeef", 2, "UDP Interface") + args, _ = mock_provider.execute.call_args + query, params = args + assert "path_hops_at_send" in query + assert "path_hops_at_send IS NULL" in query + assert params[0] == 2 + assert params[1] == "UDP Interface" + assert params[3] == "deadbeef" diff --git a/tests/backend/test_message_sending_failures.py b/tests/backend/test_message_sending_failures.py index 8d70390..33ee3b4 100644 --- a/tests/backend/test_message_sending_failures.py +++ b/tests/backend/test_message_sending_failures.py @@ -8,6 +8,7 @@ import LXMF import pytest from meshchatx.meshchat import ReticulumMeshChat +from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome @pytest.fixture @@ -19,7 +20,9 @@ def mock_app(): app.database = MagicMock() app.reticulum = MagicMock() app.message_router = MagicMock() - app._await_transport_path = AsyncMock(return_value=True) + app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(True, "reused_valid_path", False), + ) app.get_current_icon_hash = MagicMock(return_value=None) app.db_upsert_lxmf_message = MagicMock() app.websocket_broadcast = AsyncMock() @@ -235,7 +238,9 @@ async def test_send_message_db_upsert_failure_still_broadcasts(mock_app): @pytest.mark.asyncio async def test_send_message_await_path_timeout(mock_app): - mock_app._await_transport_path = AsyncMock(return_value=False) + mock_app._await_transport_path = AsyncMock( + return_value=OutboundPathOutcome(False, "new_path_requested", True), + ) destination_hash = "aa" * 16 # Even if _await_transport_path returns False, it continues to recall identity diff --git a/tests/backend/test_reticulum_pathfinding.py b/tests/backend/test_reticulum_pathfinding.py new file mode 100644 index 0000000..52aeb5c --- /dev/null +++ b/tests/backend/test_reticulum_pathfinding.py @@ -0,0 +1,358 @@ +# SPDX-License-Identifier: 0BSD + +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import RNS + +from meshchatx.src.backend import reticulum_pathfinding as rp +from meshchatx.src.backend.reticulum_pathfinding import OutboundPathOutcome + +DEST = bytes(16) + + +def _put_path_entry(dest, entry, cleanup: list) -> None: + with RNS.Transport.path_table_lock: + RNS.Transport.path_table[dest] = entry + cleanup.append(dest) + + +def test_should_rediscover_when_no_path(): + with patch.object(RNS.Transport, "has_path", return_value=False): + assert rp.should_rediscover_path(DEST) is True + + +def test_should_rediscover_when_unresponsive(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch.object(RNS.Transport, "path_is_unresponsive", return_value=True), + patch( + "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired", + return_value=False, + ), + ): + assert rp.should_rediscover_path(DEST) is True + + +def test_should_rediscover_when_expired(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch.object(RNS.Transport, "path_is_unresponsive", return_value=False), + patch( + "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired", + return_value=True, + ), + ): + assert rp.should_rediscover_path(DEST) is True + + +def test_no_rediscover_when_fresh(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch.object(RNS.Transport, "path_is_unresponsive", return_value=False), + patch( + "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired", + return_value=False, + ), + ): + assert rp.should_rediscover_path(DEST) is False + + +def test_path_metadata_no_path(): + with patch.object(RNS.Transport, "has_path", return_value=False): + m = rp.path_metadata_for_api(DEST) + assert m["path_stale"] is True + assert m["path_unresponsive"] is False + + +def test_path_table_entry_expired_ap_mode(): + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT + entry = [ + time.time() - RNS.Transport.AP_PATH_TIME - 5, + None, + 1, + 0, + None, + iface, + None, + ] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True + + +def test_path_table_entry_fresh_ap_mode(): + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_ACCESS_POINT + entry = [time.time(), None, 1, 0, None, iface, None] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is False + + +def test_prepare_fresh_drops_and_requests_when_stale(): + r = MagicMock() + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch( + "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path", + return_value=True, + ), + patch.object(RNS.Transport, "request_path") as mock_req, + ): + assert rp.prepare_fresh_path_request(r, DEST) == "path_refresh_requested" + r.drop_path.assert_called_once_with(DEST) + mock_req.assert_called_once_with(DEST) + + +def test_prepare_fresh_noop_when_reusing(): + r = MagicMock() + with ( + patch( + "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path", + return_value=False, + ), + patch.object(RNS.Transport, "request_path") as mock_req, + ): + assert rp.prepare_fresh_path_request(r, DEST) == "reused_valid_path" + r.drop_path.assert_not_called() + mock_req.assert_not_called() + + +def test_path_table_entry_expired_roaming_mode(): + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING + entry = [ + time.time() - RNS.Transport.ROAMING_PATH_TIME - 5, + None, + 1, + 0, + None, + iface, + None, + ] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True + + +def test_path_table_entry_fresh_roaming_mode(): + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING + entry = [time.time(), None, 1, 0, None, iface, None] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is False + + +def test_path_table_entry_expired_default_mode(): + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_FULL + old = time.time() - RNS.Transport.DESTINATION_TIMEOUT - 1 + entry = [old, None, 1, 0, None, iface, None] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True + + +def test_path_table_entry_expired_when_rvcd_if_is_none(): + old = time.time() - RNS.Transport.DESTINATION_TIMEOUT - 1 + entry = [old, None, 1, 0, None, None, None] + assert rp._path_table_entry_is_expired_by_reticulum_rules(entry) is True + + +def test_transport_path_table_entry_is_expired_uses_path_table(): + to_del = [] + dest = bytes([0x1B] * 16) + iface = MagicMock() + iface.mode = RNS.Interfaces.Interface.Interface.MODE_ROAMING + _put_path_entry( + dest, + [ + time.time() - RNS.Transport.ROAMING_PATH_TIME - 2, + None, + 1, + 0, + None, + iface, + None, + ], + to_del, + ) + try: + assert rp.transport_path_table_entry_is_expired(dest) is True + finally: + with RNS.Transport.path_table_lock: + for d in to_del: + RNS.Transport.path_table.pop(d, None) + + +def test_path_metadata_when_has_path_stale(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch( + "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired", + return_value=True, + ), + patch.object(RNS.Transport, "path_is_unresponsive", return_value=False), + ): + m = rp.path_metadata_for_api(DEST) + assert m == {"path_stale": True, "path_unresponsive": False} + + +def test_path_metadata_when_unresponsive(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch( + "meshchatx.src.backend.reticulum_pathfinding.transport_path_table_entry_is_expired", + return_value=False, + ), + patch.object(RNS.Transport, "path_is_unresponsive", return_value=True), + ): + m = rp.path_metadata_for_api(DEST) + assert m == {"path_stale": False, "path_unresponsive": True} + + +def test_prepare_fresh_uses_expire_path_without_reticulum(): + with ( + patch.object(RNS.Transport, "has_path", return_value=True), + patch( + "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path", + return_value=True, + ), + patch.object(RNS.Transport, "expire_path") as ex, + patch.object(RNS.Transport, "request_path") as req, + ): + assert rp.prepare_fresh_path_request(None, DEST) == "path_refresh_requested" + ex.assert_called_once_with(DEST) + req.assert_called_once_with(DEST) + + +def test_lxmf_path_wait_cap_uses_rns_default(): + v = rp.lxmf_path_wait_cap_seconds() + assert 30.0 <= v <= 120.0 + + +def test_lxmf_path_wait_cap_falls_back_when_float_fails(): + with patch.object(RNS.Transport, "PATH_REQUEST_TIMEOUT", "x"): + assert rp.lxmf_path_wait_cap_seconds() == 30.0 + + +def test_nudge_path_request_forwards_to_transport(): + with patch.object(RNS.Transport, "request_path") as m: + rp.nudge_path_request(DEST) + m.assert_called_once_with(DEST) + + +@pytest.mark.asyncio +async def test_meshchat_await_transport_path_delegates_to_module(): + from meshchatx.meshchat import ReticulumMeshChat + + outcome = OutboundPathOutcome(True, "reused_valid_path", False) + with patch( + "meshchatx.meshchat.reticulum_pathfinding.await_transport_path_for_outbound_lxmf", + new=AsyncMock(return_value=outcome), + ) as m: + inst = object.__new__(ReticulumMeshChat) + inst.reticulum = object() + r = await ReticulumMeshChat._await_transport_path(inst, DEST) + assert r.path_available is True + m.assert_called_once_with(inst.reticulum, DEST) + + +@pytest.mark.asyncio +async def test_await_outbound_lxmf_returns_true_when_path_immediate(): + with ( + patch( + "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request", + return_value="reused_valid_path", + ) as prep, + patch.object( + RNS.Transport, + "has_path", + return_value=True, + ), + ): + out = await rp.await_transport_path_for_outbound_lxmf(MagicMock(), DEST) + assert out.path_available is True + assert out.prepare_measure == "reused_valid_path" + assert out.used_nudge is False + prep.assert_called_once() + + +def test_format_outbound_path_finding_measure_appends_nudge(): + o = OutboundPathOutcome(True, "new_path_requested", True) + assert rp.format_outbound_path_finding_measure(o) == "new_path_requested+nudge" + o2 = OutboundPathOutcome(True, "reused_valid_path", False) + assert rp.format_outbound_path_finding_measure(o2) == "reused_valid_path" + + +def test_prepare_fresh_requests_when_no_path_but_rediscover(): + r = MagicMock() + with ( + patch.object(RNS.Transport, "has_path", return_value=False), + patch( + "meshchatx.src.backend.reticulum_pathfinding.should_rediscover_path", + return_value=True, + ), + patch.object(RNS.Transport, "request_path") as mock_req, + ): + assert rp.prepare_fresh_path_request(r, DEST) == "new_path_requested" + r.drop_path.assert_not_called() + mock_req.assert_called_once_with(DEST) + + +@pytest.mark.asyncio +async def test_await_outbound_lxmf_returns_false_after_waits_exhausted(): + nudge = MagicMock() + with ( + patch( + "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request", + ), + patch( + "meshchatx.src.backend.reticulum_pathfinding.lxmf_path_wait_cap_seconds", + return_value=0.0, + ), + patch( + "meshchatx.src.backend.reticulum_pathfinding.nudge_path_request", + nudge, + ), + patch.object(RNS.Transport, "has_path", return_value=False), + patch( + "meshchatx.src.backend.reticulum_pathfinding.asyncio.sleep", + new_callable=AsyncMock, + ), + patch( + "meshchatx.src.backend.reticulum_pathfinding.time.time", + side_effect=[0.0, 0.0, 0.0, 0.0, 100.0], + ), + ): + out = await rp.await_transport_path_for_outbound_lxmf(MagicMock(), DEST) + assert out.path_available is False + assert out.used_nudge is True + nudge.assert_called_once() + + +@pytest.mark.asyncio +async def test_wait_for_path_returns_true_immediately(): + with ( + patch( + "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request", + ) as p, + patch.object(RNS.Transport, "has_path", return_value=True), + ): + ok = await rp.wait_for_path(MagicMock(), DEST, 5.0, 0.01) + assert ok is True + p.assert_called_once() + + +@pytest.mark.asyncio +async def test_wait_for_path_times_out(): + with ( + patch( + "meshchatx.src.backend.reticulum_pathfinding.prepare_fresh_path_request", + ), + patch.object(RNS.Transport, "has_path", return_value=False), + patch( + "meshchatx.src.backend.reticulum_pathfinding.asyncio.sleep", + new_callable=AsyncMock, + ), + patch( + "meshchatx.src.backend.reticulum_pathfinding.time.monotonic", + side_effect=[0.0, 0.0, 0.02], + ), + ): + ok = await rp.wait_for_path(MagicMock(), DEST, 0.01, 0.01) + assert ok is False diff --git a/tests/backend/test_reticulum_pathfinding_http.py b/tests/backend/test_reticulum_pathfinding_http.py new file mode 100644 index 0000000..0cc0755 --- /dev/null +++ b/tests/backend/test_reticulum_pathfinding_http.py @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: 0BSD + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + + +@pytest.mark.asyncio +async def test_get_destination_path_with_request_calls_prepare_fresh(mock_app): + mock_app.reticulum = MagicMock() + mock_app.reticulum.get_next_hop.return_value = bytes(16) + mock_app.reticulum.get_next_hop_if_name.return_value = "if0" + h = next( + r.handler + for r in mock_app.get_routes() + if r.path == "/api/v1/destination/{destination_hash}/path" + ) + dest = "a" * 32 + req = SimpleNamespace( + match_info={"destination_hash": dest}, + query={"request": "1", "timeout": "1"}, + ) + with ( + patch( + "meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request", + ) as pfp, + patch( + "meshchatx.meshchat.reticulum_pathfinding.path_metadata_for_api", + return_value={"path_stale": False, "path_unresponsive": False}, + ), + patch("meshchatx.meshchat.RNS.Transport.has_path", return_value=True), + patch("meshchatx.meshchat.RNS.Transport.hops_to", return_value=2), + patch("meshchatx.meshchat.asyncio.sleep", new_callable=AsyncMock), + ): + response = await h(req) + pfp.assert_called_once() + assert pfp.call_args[0][0] is mock_app.reticulum + assert pfp.call_args[0][1] == bytes.fromhex(dest) + data = json.loads(response.body) + assert data["path"]["hops"] == 2 + assert data["path_stale"] is False + assert data["path_unresponsive"] is False + + +@pytest.mark.asyncio +async def test_post_destination_request_path_calls_prepare_fresh(mock_app): + mock_app.reticulum = MagicMock() + h = next( + r.handler + for r in mock_app.get_routes() + if r.path == "/api/v1/destination/{destination_hash}/request-path" + ) + dest = "b" * 32 + req = SimpleNamespace(match_info={"destination_hash": dest}) + with patch( + "meshchatx.meshchat.reticulum_pathfinding.prepare_fresh_path_request", + ) as pfp: + response = await h(req) + pfp.assert_called_once() + assert pfp.call_args[0][0] is mock_app.reticulum + assert pfp.call_args[0][1] == bytes.fromhex(dest) + assert response.status == 200 + data = json.loads(response.body) + assert data["message"] == "ok" diff --git a/tests/backend/test_rnode_download_firmware.py b/tests/backend/test_rnode_download_firmware.py index ccf3a00..93b286f 100644 --- a/tests/backend/test_rnode_download_firmware.py +++ b/tests/backend/test_rnode_download_firmware.py @@ -50,7 +50,7 @@ class _FakeSession: async def __aexit__(self, exc_type, exc, tb): return False - def get(self, url, allow_redirects=True): + def get(self, url, allow_redirects=True, headers=None): self.requested_urls.append(url) status = self._status body = self._body @@ -172,6 +172,7 @@ class _FakeJsonSession: self._status = status self._payload = payload self.requested_urls: list[str] = [] + self.last_headers = None async def __aenter__(self): return self @@ -179,8 +180,9 @@ class _FakeJsonSession: async def __aexit__(self, exc_type, exc, tb): return False - def get(self, url, allow_redirects=True): + def get(self, url, allow_redirects=True, headers=None): self.requested_urls.append(url) + self.last_headers = headers status = self._status payload = self._payload @@ -191,6 +193,64 @@ class _FakeJsonSession: return _cm() +@pytest.mark.asyncio +async def test_download_firmware_accepts_objects_githubusercontent_url(web_app): + aio_app = _build_aio_app(web_app) + fake_zip = b"PK\x03\x04x" + fake_session = _FakeSession(200, fake_zip) + asset_url = ( + "https://objects.githubusercontent.com/github-production-release-asset/1/2/3" + "?response-content-disposition=attachment%3B%20filename%3Dfw.zip" + ) + + with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)): + async with TestClient(TestServer(aio_app)) as client: + r = await client.get( + "/api/v1/tools/rnode/download_firmware", + params={"url": asset_url}, + ) + assert r.status == 200 + assert fake_session.requested_urls == [asset_url] + + +@pytest.mark.asyncio +async def test_download_firmware_accepts_release_assets_githubusercontent_url(web_app): + aio_app = _build_aio_app(web_app) + fake_zip = b"PK\x03\x04y" + fake_session = _FakeSession(200, fake_zip) + asset_url = "https://release-assets.githubusercontent.com/github-production-release-asset/9/8/7/fw.zip" + + with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)): + async with TestClient(TestServer(aio_app)) as client: + r = await client.get( + "/api/v1/tools/rnode/download_firmware", + params={"url": asset_url}, + ) + assert r.status == 200 + data = await r.read() + assert data == fake_zip + + +@pytest.mark.asyncio +async def test_download_firmware_accepts_configured_gitea_base_url(web_app): + aio_app = _build_aio_app(web_app) + web_app.config.gitea_base_url.set("https://gitea.custom.example") + fake_zip = b"PK\x03\x04z" + fake_session = _FakeSession(200, fake_zip) + asset_url = ( + "https://gitea.custom.example/someorg/somerepo/releases/download/v1/x.zip" + ) + + with patch("aiohttp.ClientSession", MagicMock(return_value=fake_session)): + async with TestClient(TestServer(aio_app)) as client: + r = await client.get( + "/api/v1/tools/rnode/download_firmware", + params={"url": asset_url}, + ) + assert r.status == 200 + assert fake_session.requested_urls == [asset_url] + + @pytest.mark.asyncio async def test_latest_release_returns_proxied_payload(web_app): aio_app = _build_aio_app(web_app) @@ -214,8 +274,16 @@ async def test_latest_release_returns_proxied_payload(web_app): assert r.status == 200 body = await r.json() assert body == payload - assert fake_session.requested_urls[0].endswith( - "/api/v1/repos/Reticulum/RNode_Firmware/releases/latest" + assert fake_session.requested_urls[0] == ( + "https://api.github.com/repos/markqvist/RNode_Firmware/releases/latest" + ) + assert fake_session.last_headers is not None + assert ( + fake_session.last_headers.get("Accept") == "application/vnd.github+json" + ) + assert fake_session.last_headers.get("X-GitHub-Api-Version") == "2022-11-28" + assert "MeshChatX-RNodeFlasher" in fake_session.last_headers.get( + "User-Agent", "" ) @@ -234,8 +302,8 @@ async def test_latest_release_uses_repo_query_param(web_app): params={"repo": "Some/Other_Repo"}, ) assert r.status == 200 - assert fake_session.requested_urls[0].endswith( - "/api/v1/repos/Some/Other_Repo/releases/latest" + assert fake_session.requested_urls[0] == ( + "https://api.github.com/repos/Some/Other_Repo/releases/latest" ) @@ -243,7 +311,16 @@ async def test_latest_release_uses_repo_query_param(web_app): async def test_latest_release_rejects_invalid_repo(web_app): aio_app = _build_aio_app(web_app) async with TestClient(TestServer(aio_app)) as client: - for repo in ("no-slash", "../etc/passwd", "evil repo/x", "bad?repo/x"): + for repo in ( + "no-slash", + "../etc/passwd", + "evil repo/x", + "bad?repo/x", + "too/many/slashes", + "@bad/name", + "/leading/slash", + "trailing/", + ): r = await client.get( "/api/v1/tools/rnode/latest_release", params={"repo": repo}, diff --git a/tests/backend/test_startup.py b/tests/backend/test_startup.py index b44b46a..286a023 100644 --- a/tests/backend/test_startup.py +++ b/tests/backend/test_startup.py @@ -126,7 +126,8 @@ def test_reticulum_meshchat_init(mock_rns, temp_dir): mock_config_instance.libretranslate_url.get.return_value = ( "http://localhost:5000" ) - mock_config_instance.translator_enabled.get.return_value = False + mock_config_instance.translator_argos_enabled.get.return_value = False + mock_config_instance.translator_libretranslate_enabled.get.return_value = False app = ReticulumMeshChat( identity=mock_rns["id_instance"], diff --git a/tests/backend/test_startup_advanced.py b/tests/backend/test_startup_advanced.py index 95ef0ad..6b58b1c 100644 --- a/tests/backend/test_startup_advanced.py +++ b/tests/backend/test_startup_advanced.py @@ -115,7 +115,8 @@ def test_run_https_logic(mock_rns, temp_dir): mock_config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None mock_config.lxmf_local_propagation_node_enabled.get.return_value = False mock_config.libretranslate_url.get.return_value = "http://localhost:5000" - mock_config.translator_enabled.get.return_value = False + mock_config.translator_argos_enabled.get.return_value = False + mock_config.translator_libretranslate_enabled.get.return_value = False app = ReticulumMeshChat( identity=mock_rns["id_instance"], @@ -275,7 +276,8 @@ def test_database_health_issues_set_on_setup(mock_rns, temp_dir): mock_config.lxmf_preferred_propagation_node_destination_hash.get.return_value = None mock_config.lxmf_local_propagation_node_enabled.get.return_value = False mock_config.libretranslate_url.get.return_value = "http://localhost:5000" - mock_config.translator_enabled.get.return_value = False + mock_config.translator_argos_enabled.get.return_value = False + mock_config.translator_libretranslate_enabled.get.return_value = False app = ReticulumMeshChat( identity=mock_rns["id_instance"], diff --git a/tests/backend/test_translator_argos_integration.py b/tests/backend/test_translator_argos_integration.py index a4307f7..0e468d7 100644 --- a/tests/backend/test_translator_argos_integration.py +++ b/tests/backend/test_translator_argos_integration.py @@ -59,7 +59,10 @@ def test_find_argos_cli_matches_shutil_which(): reason="Network unreachable (Argos CLI may download Stanza resources)", ) def test_translate_en_es_via_cli_round_trip(): - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler( + translator_argos_enabled=True, + translator_libretranslate_enabled=True, + ) assert handler.has_argos_cli assert not handler.has_argos_lib @@ -89,7 +92,10 @@ def test_translate_en_es_via_cli_round_trip(): @pytest.mark.integration @pytest.mark.skipif(not _argos_cli_on_path(), reason="Argos CLI not on PATH") def test_get_supported_languages_includes_argos_when_libretranslate_down(): - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler( + translator_argos_enabled=True, + translator_libretranslate_enabled=True, + ) langs = handler.get_supported_languages() argos = [x for x in langs if x.get("source") == "argos"] assert len(argos) >= 1 diff --git a/tests/backend/test_translator_config_migration.py b/tests/backend/test_translator_config_migration.py new file mode 100644 index 0000000..1f452b0 --- /dev/null +++ b/tests/backend/test_translator_config_migration.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: 0BSD + +import os +import tempfile + +from meshchatx.src.backend.config_manager import ConfigManager +from meshchatx.src.backend.database import Database + + +def test_migrates_legacy_translator_enabled_to_per_backend_keys(): + with tempfile.TemporaryDirectory() as tmpdir: + db_path = os.path.join(tmpdir, "t.db") + db = Database(db_path) + db.initialize() + db.config.set("translator_enabled", "true") + config = ConfigManager(db) + assert config.translator_argos_enabled.get() is True + assert config.translator_libretranslate_enabled.get() is True diff --git a/tests/backend/test_translator_handler.py b/tests/backend/test_translator_handler.py index 28fd054..2f36057 100644 --- a/tests/backend/test_translator_handler.py +++ b/tests/backend/test_translator_handler.py @@ -27,11 +27,17 @@ def _mock_session_for_languages(): class TestTranslatorHandler(unittest.TestCase): def setUp(self): - self.handler = TranslatorHandler(enabled=True) + self.handler = TranslatorHandler( + translator_libretranslate_enabled=True, + translator_argos_enabled=False, + ) @patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") def test_get_supported_languages(self, mock_session_cls): self.handler.has_requests = True + self.handler.has_argos = False + self.handler.has_argos_lib = False + self.handler.has_argos_cli = False mock_session_cls.return_value = _mock_session_for_languages() langs = self.handler.get_supported_languages() @@ -41,6 +47,7 @@ class TestTranslatorHandler(unittest.TestCase): @patch("meshchatx.src.backend.translator_handler.aiohttp.ClientSession") def test_translate_text_libretranslate(self, mock_session_cls): self.handler.has_requests = True + self.handler.translator_libretranslate_enabled = True mock_response = MagicMock() mock_response.status = 200 mock_response.json = AsyncMock( @@ -58,7 +65,7 @@ class TestTranslatorHandler(unittest.TestCase): mock_session.__aexit__ = AsyncMock(return_value=None) mock_session_cls.return_value = mock_session - result = self.handler.translate_text("Hello", "en", "de") + result = self.handler.translate_text("Hello", "en", "de", use_argos=False) self.assertEqual(result["translated_text"], "Hallo") self.assertEqual(result["source"], "libretranslate") diff --git a/tests/backend/test_translator_handler_extended.py b/tests/backend/test_translator_handler_extended.py index e1557f2..d11ca76 100644 --- a/tests/backend/test_translator_handler_extended.py +++ b/tests/backend/test_translator_handler_extended.py @@ -8,13 +8,21 @@ from meshchatx.src.backend.translator_handler import TranslatorHandler def test_translator_handler_init(): - handler = TranslatorHandler(libretranslate_url="http://test:5000", enabled=True) + handler = TranslatorHandler( + libretranslate_url="http://test:5000", + translator_argos_enabled=True, + translator_libretranslate_enabled=True, + ) assert handler.libretranslate_url == "http://test:5000" - assert handler.enabled is True + assert handler.translator_argos_enabled is True -def test_get_supported_languages_disabled(): - handler = TranslatorHandler(enabled=False) +def test_get_supported_languages_no_backends(): + handler = TranslatorHandler() + handler.has_requests = False + handler.has_argos = False + handler.has_argos_lib = False + handler.has_argos_cli = False assert handler.get_supported_languages() == [] @@ -37,7 +45,11 @@ def test_get_supported_languages_libretranslate(mock_session_cls): mock_session.__aexit__ = AsyncMock(return_value=None) mock_session_cls.return_value = mock_session - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler() + handler.has_argos = False + handler.has_argos_lib = False + handler.has_argos_cli = False + handler.has_requests = True langs = handler.get_supported_languages() assert len(langs) == 2 assert langs[0]["code"] == "en" @@ -58,7 +70,8 @@ def test_translate_libretranslate(mock_session_cls): mock_session.__aexit__ = AsyncMock(return_value=None) mock_session_cls.return_value = mock_session - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler(translator_libretranslate_enabled=True) + handler.has_requests = True result = handler.translate_text("Hello", source_lang="en", target_lang="fr") assert result["translated_text"] == "Bonjour" @@ -69,7 +82,9 @@ def test_translate_argos_cli(mock_run): mock_result.stdout = "Hola" mock_run.return_value = mock_result - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler( + translator_argos_enabled=True, translator_libretranslate_enabled=False + ) handler.has_argos_cli = True handler.has_argos = True handler.has_requests = False # Force CLI @@ -86,7 +101,7 @@ def test_translate_argos_cli(mock_run): def test_detect_language_simple(): - TranslatorHandler(enabled=True) + TranslatorHandler() # _detect_language is private @@ -109,17 +124,22 @@ def test_detect_language_libretranslate(mock_session_cls): mock_session.__aexit__ = AsyncMock(return_value=None) mock_session_cls.return_value = mock_session - handler = TranslatorHandler(enabled=True) + handler = TranslatorHandler(translator_libretranslate_enabled=True) + handler.has_requests = True result = handler.translate_text("Hello world", source_lang="auto", target_lang="fr") assert result["source_lang"] == "en" def test_translator_handler_errors(): - handler = TranslatorHandler(enabled=False) + handler = TranslatorHandler( + translator_argos_enabled=False, + translator_libretranslate_enabled=False, + ) with pytest.raises(RuntimeError, match="Translator is disabled"): handler.translate_text("Hello", "en", "fr") - handler.enabled = True + handler.translator_argos_enabled = True + handler.translator_libretranslate_enabled = True with pytest.raises(ValueError, match="Text cannot be empty"): handler.translate_text("", "en", "fr") diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js index 6c702b2..6b4fdc4 100644 --- a/tests/frontend/AboutPage.test.js +++ b/tests/frontend/AboutPage.test.js @@ -18,6 +18,7 @@ describe("AboutPage.vue", () => { let axiosMock; beforeEach(() => { + vi.clearAllMocks(); vi.useFakeTimers(); axiosMock = { get: vi.fn().mockImplementation(() => Promise.resolve({ data: {} })), @@ -212,7 +213,7 @@ describe("AboutPage.vue", () => { expect(axiosMock.get).toHaveBeenCalledTimes(7); // +2 from updateInterval }); - it("handles vacuum database action", async () => { + it("handles vacuum database action and shows success toast", async () => { axiosMock.get.mockResolvedValue({ data: { app_info: {}, @@ -232,12 +233,111 @@ describe("AboutPage.vue", () => { const wrapper = mountAboutPage(); await wrapper.vm.$nextTick(); - // Find vacuum button (it's the second button in the database health section) - // Or we can just call the method directly to be sure await wrapper.vm.vacuumDatabase(); expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/vacuum"); expect(wrapper.vm.databaseActionMessage).toBe("Vacuum success"); + expect(ToastUtils.success).toHaveBeenCalledWith("about.vacuum_complete"); + }); + + it("shows error toast when vacuum fails", async () => { + axiosMock.get.mockResolvedValue({ + data: { + app_info: {}, + config: {}, + database: {}, + }, + }); + const apiErr = new Error("vacuum failed"); + apiErr.response = { data: { message: "Failed to vacuum database: locked" } }; + axiosMock.post.mockRejectedValue(apiErr); + + const wrapper = mountAboutPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.vacuumDatabase(); + + expect(ToastUtils.error).toHaveBeenCalledWith("Failed to vacuum database: locked"); + expect(wrapper.vm.databaseActionError).toBe("about.vacuum_failed"); + }); + + it("handles database recovery and shows success toast", async () => { + vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true); + axiosMock.get.mockResolvedValue({ + data: { + app_info: {}, + config: {}, + database: { + quick_check: "ok", + journal_mode: "wal", + page_count: 1, + estimated_free_bytes: 0, + }, + }, + }); + axiosMock.post.mockImplementation((url) => { + if (url === "/api/v1/database/recover") { + return Promise.resolve({ + data: { + message: "Database recovery routine completed", + database: { + health: { + quick_check: "ok", + journal_mode: "wal", + page_count: 2, + estimated_free_bytes: 100, + }, + actions: [{ step: "wal_checkpoint", result: [] }], + }, + }, + }); + } + return Promise.resolve({ data: {} }); + }); + + const wrapper = mountAboutPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.runRecovery(); + + expect(DialogUtils.confirm).toHaveBeenCalledWith("about.recovery_confirm"); + expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/database/recover"); + expect(ToastUtils.success).toHaveBeenCalledWith("about.recovery_complete"); + expect(wrapper.vm.databaseRecoveryActions.length).toBe(1); + }); + + it("does not run recovery when user cancels the confirm dialog", async () => { + vi.spyOn(DialogUtils, "confirm").mockResolvedValue(false); + axiosMock.get.mockResolvedValue({ + data: { app_info: {}, config: {}, database: {} }, + }); + + const wrapper = mountAboutPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.runRecovery(); + + expect(DialogUtils.confirm).toHaveBeenCalledWith("about.recovery_confirm"); + expect(axiosMock.post).not.toHaveBeenCalledWith("/api/v1/database/recover"); + expect(ToastUtils.success).not.toHaveBeenCalledWith("about.recovery_complete"); + }); + + it("shows error toast when recovery fails", async () => { + vi.spyOn(DialogUtils, "confirm").mockResolvedValue(true); + axiosMock.get.mockResolvedValue({ + data: { app_info: {}, config: {}, database: {} }, + }); + const apiErr = new Error("recover failed"); + apiErr.response = { data: { message: "Failed to recover database: corrupt" } }; + axiosMock.post.mockRejectedValue(apiErr); + + const wrapper = mountAboutPage(); + await wrapper.vm.$nextTick(); + + await wrapper.vm.runRecovery(); + + expect(ToastUtils.error).toHaveBeenCalledWith("Failed to recover database: corrupt"); + expect(wrapper.vm.databaseActionError).toBe("about.recovery_failed"); }); it("displays Free Space from database health", async () => { diff --git a/tests/frontend/AppSidebarIdentityAnnounce.test.js b/tests/frontend/AppSidebarIdentityAnnounce.test.js new file mode 100644 index 0000000..3e5635e --- /dev/null +++ b/tests/frontend/AppSidebarIdentityAnnounce.test.js @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mount, flushPromises } from "@vue/test-utils"; +import { createRouter, createWebHashHistory } from "vue-router"; +import { createI18n } from "vue-i18n"; +import { createVuetify } from "vuetify"; +import App from "../../meshchatx/src/frontend/components/App.vue"; +import { appPackageVersion } from "./fixtures/repoPackageVersion.js"; +import en from "../../meshchatx/src/frontend/locales/en.json"; +import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils"; + +vi.mock("../../meshchatx/src/frontend/js/WebSocketConnection", () => ({ + default: { + connect: vi.fn(), + on: vi.fn(), + off: vi.fn(), + send: vi.fn(), + destroy: vi.fn(), + }, +})); + +vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +const axiosMock = { get: vi.fn() }; +const vuetify = createVuetify(); +const i18n = createI18n({ + legacy: false, + locale: "en", + messages: { en }, +}); + +const routes = [ + { path: "/", name: "messages", component: { template: "
Messages
" } }, + { path: "/nomadnetwork", name: "nomadnetwork", component: { template: "
Nomad
" } }, + { path: "/contacts", name: "contacts", component: { template: "
Contacts
" } }, + { path: "/map", name: "map", component: { template: "
Map
" } }, + { path: "/archives", name: "archives", component: { template: "
Archives
" } }, + { path: "/call", name: "call", component: { template: "
Call
" } }, + { path: "/interfaces", name: "interfaces", component: { template: "
Interfaces
" } }, + { path: "/network-visualiser", name: "network-visualiser", component: { template: "
Network
" } }, + { path: "/tools", name: "tools", component: { template: "
Tools
" } }, + { path: "/settings", name: "settings", component: { template: "
Settings
" } }, + { path: "/identities", name: "identities", component: { template: "
Identities
" } }, + { path: "/about", name: "about", component: { template: "
About
" } }, + { path: "/profile/icon", name: "profile.icon", component: { template: "
Profile
" } }, + { path: "/changelog", name: "changelog", component: { template: "
Changelog
" } }, + { path: "/tutorial", name: "tutorial", component: { template: "
Tutorial
" } }, +]; + +const appStubs = { + MaterialDesignIcon: { template: '' }, + LxmfUserIcon: { template: "
" }, + NotificationBell: true, + LanguageSelector: true, + CallOverlay: true, + CommandPalette: true, + IntegrityWarningModal: true, + AppShellBanners: true, + Toast: true, + VDialog: true, + VCard: true, + VCardText: true, + VCardActions: true, + VBtn: true, + VIcon: true, + VToolbar: true, + VToolbarTitle: true, + VSpacer: true, + VProgressCircular: true, + VCheckbox: true, + VDivider: true, +}; + +function makeConfig(overrides = {}) { + return { + theme: "dark", + display_name: "Test User", + auto_announce_interval_seconds: 0, + last_announced_at: null, + identity_hash: "h1", + lxmf_address_hash: "lx1", + identity_public_key: "pk1", + lxmf_user_icon_name: "face-man", + lxmf_user_icon_foreground_colour: "#e4e4e7", + lxmf_user_icon_background_colour: "#3f3f46", + language: "en", + ...overrides, + }; +} + +function defaultAxiosImplementation(url) { + if (url === "/api/v1/app/info") { + return Promise.resolve({ + data: { + app_info: { + version: appPackageVersion, + tutorial_seen: true, + changelog_seen_version: appPackageVersion, + }, + }, + }); + } + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: makeConfig() } }); + } + if (url === "/api/v1/announce") { + return Promise.resolve({ data: {} }); + } + if (url === "/api/v1/auth/status") { + return Promise.resolve({ data: { auth_enabled: false } }); + } + if (url === "/api/v1/blocked-destinations") { + return Promise.resolve({ data: { blocked_destinations: [] } }); + } + if (url === "/api/v1/telephone/status") { + return Promise.resolve({ data: { active_call: null } }); + } + if (url === "/api/v1/lxmf/propagation-node/status") { + return Promise.resolve({ data: { propagation_node_status: { state: "idle" } } }); + } + return Promise.resolve({ data: {} }); +} + +function makeMountedApp() { + const router = createRouter({ + history: createWebHashHistory(), + routes, + }); + return mount(App, { + global: { + plugins: [router, vuetify, i18n], + stubs: appStubs, + }, + }); +} + +describe("App.vue sidebar identity label and announce control", () => { + let wrapper; + + beforeEach(() => { + window.api = axiosMock; + vi.clearAllMocks(); + axiosMock.get.mockImplementation(defaultAxiosImplementation); + }); + + afterEach(() => { + if (wrapper) { + wrapper.unmount(); + wrapper = undefined; + } + delete window.api; + }); + + async function readyShell(r) { + await r.isReady(); + await flushPromises(); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + it("shows configured display name instead of My Identity", async () => { + wrapper = makeMountedApp(); + const r = wrapper.vm.$router; + await readyShell(r); + const html = wrapper.html(); + expect(html).toContain("Test User"); + expect(html).not.toMatch(/>My Identity { + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: makeConfig({ display_name: "" }) } }); + } + return defaultAxiosImplementation(url); + }); + wrapper = makeMountedApp(); + await readyShell(wrapper.vm.$router); + expect(wrapper.html()).toContain("My Identity"); + }); + + it("long display name is exposed in title and uses truncate for layout", async () => { + const long = "A".repeat(200); + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: makeConfig({ display_name: long }) } }); + } + return defaultAxiosImplementation(url); + }); + wrapper = makeMountedApp(); + await readyShell(wrapper.vm.$router); + expect(wrapper.vm.identitySidebarLabel).toBe(long); + const titled = wrapper.find(`div[title="${long}"]`); + expect(titled.exists()).toBe(true); + expect(titled.attributes("class") ?? "").toMatch(/truncate/); + }); + + it("sidebar radio sends announce and still works when sidebar is collapsed", async () => { + wrapper = makeMountedApp(); + await readyShell(wrapper.vm.$router); + const btn = wrapper.find("[data-testid=sidebar-announce-radio]"); + expect(btn.exists()).toBe(true); + wrapper.vm.isShowingAnnounceSection = true; + await btn.trigger("click"); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce"); + expect(ToastUtils.success).toHaveBeenCalled(); + expect(wrapper.vm.isShowingAnnounceSection).toBe(true); + vi.clearAllMocks(); + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/announce") { + return Promise.resolve({ data: {} }); + } + if (url === "/api/v1/config") { + return Promise.resolve({ data: { config: makeConfig() } }); + } + return defaultAxiosImplementation(url); + }); + wrapper.vm.isSidebarCollapsed = true; + await btn.trigger("click"); + expect(axiosMock.get).toHaveBeenCalledWith("/api/v1/announce"); + }); + + it("clicking announce section header (not the radio) toggles expanded state", async () => { + wrapper = makeMountedApp(); + await readyShell(wrapper.vm.$router); + const header = wrapper.find("[data-testid=sidebar-announce-header]"); + expect(header.exists()).toBe(true); + wrapper.vm.isShowingAnnounceSection = true; + await header.trigger("click"); + expect(wrapper.vm.isShowingAnnounceSection).toBe(false); + }); +}); diff --git a/tests/frontend/ConversationViewer.test.js b/tests/frontend/ConversationViewer.test.js index ca9456a..b507280 100644 --- a/tests/frontend/ConversationViewer.test.js +++ b/tests/frontend/ConversationViewer.test.js @@ -4,6 +4,8 @@ import ConversationViewer from "@/components/messages/ConversationViewer.vue"; import WebSocketConnection from "@/js/WebSocketConnection"; import GlobalState from "@/js/GlobalState"; import DialogUtils from "@/js/DialogUtils"; +import ToastUtils from "@/js/ToastUtils"; +import { MESSAGE_BODY_MAX_DISPLAY_CHARS } from "../../meshchatx/src/frontend/js/messageDisplayLimits.js"; vi.mock("@/js/DialogUtils", () => ({ default: { @@ -486,13 +488,107 @@ describe("ConversationViewer.vue", () => { ...navigator, clipboard: { readText }, }); + const prevSc = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); + try { + const wrapper = mountConversationViewer(); + const ta = wrapper.find("#message-input").element; + ta.selectionStart = 0; + ta.selectionEnd = 0; + wrapper.vm.newMessageText = ""; + await wrapper.vm.pasteFromClipboard(); + expect(wrapper.vm.newMessageText).toBe("pasted-text"); + } finally { + Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc }); + } + }); + + it("pasteFromClipboard toasts insecure context and does not call readText", async () => { + const readText = vi.fn(() => Promise.resolve("never")); + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { readText }, + }); + const prevSc = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); + const errSpy = vi.spyOn(ToastUtils, "error").mockImplementation(() => {}); + try { + const wrapper = mountConversationViewer(); + await wrapper.vm.pasteFromClipboard(); + expect(readText).not.toHaveBeenCalled(); + expect(errSpy).toHaveBeenCalledWith("messages.clipboard_read_requires_secure_context"); + } finally { + errSpy.mockRestore(); + Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc }); + } + }); + + it("showRawMessage loads raw uri and keeps stored path fields from the message", async () => { + const peer = "a".repeat(32); + const msgHash = "b".repeat(32); + axiosMock.get.mockImplementation((url) => { + if (url.includes("/lxmf-messages/") && url.includes("/uri")) { + return Promise.resolve({ data: { uri: "lxmf://packed-uri" } }); + } + if (url.includes("/destination/") && url.includes("/path")) { + return Promise.resolve({ + data: { path: { hops: 99, next_hop_interface: "Should not use" } }, + }); + } + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + return Promise.resolve({ data: {} }); + }); + const wrapper = mountConversationViewer({ + selectedPeer: { destination_hash: peer, display_name: "Peer" }, + myLxmfAddressHash: "c".repeat(32), + }); + const getCallsBefore = axiosMock.get.mock.calls.length; + await wrapper.vm.showRawMessage({ + lxmf_message: { + hash: msgHash, + source_hash: "d".repeat(32), + destination_hash: peer, + state: "delivered", + method: "direct", + content: "hi", + fields: {}, + id: 42, + path_hops_at_send: 3, + path_interface_at_send: "Default Interface", + }, + }); + expect(wrapper.vm.isRawMessageModalOpen).toBe(true); + expect(wrapper.vm.rawMessageData.raw_uri).toBe("lxmf://packed-uri"); + expect(wrapper.vm.rawMessageData.path_hops_at_send).toBe(3); + expect(wrapper.vm.rawMessageData.path_interface_at_send).toBe("Default Interface"); + const callsDuringRaw = axiosMock.get.mock.calls.slice(getCallsBefore); + const destinationPathCalls = callsDuringRaw.filter( + (c) => typeof c[0] === "string" && c[0].includes("/destination/") && c[0].includes("/path") + ); + expect(destinationPathCalls.length).toBe(0); + expect(callsDuringRaw.some((c) => c[0].includes("/uri"))).toBe(true); + }); + + it("isMessageBodyTooLargeForDisplay is true only above display limit", () => { const wrapper = mountConversationViewer(); - const ta = wrapper.find("#message-input").element; - ta.selectionStart = 0; - ta.selectionEnd = 0; - wrapper.vm.newMessageText = ""; - await wrapper.vm.pasteFromClipboard(); - expect(wrapper.vm.newMessageText).toBe("pasted-text"); + const atLimit = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS) } }; + const over = { lxmf_message: { content: "x".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 1) } }; + expect(wrapper.vm.isMessageBodyTooLargeForDisplay(atLimit)).toBe(false); + expect(wrapper.vm.isMessageBodyTooLargeForDisplay(over)).toBe(true); + }); + + it("rawMessageJsonPreviewPretty replaces huge content for JSON details", async () => { + const wrapper = mountConversationViewer(); + const huge = "z".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 5000); + await wrapper.setData({ + rawMessageData: { id: 1, content: huge, hash: "b".repeat(32) }, + }); + const s = wrapper.vm.rawMessageJsonPreviewPretty; + expect(s).not.toContain(huge); + expect(s).toContain("Omitted"); + expect(s).toContain(String(huge.length)); }); it("adds multiple images and renders previews", async () => { @@ -822,7 +918,7 @@ describe("ConversationViewer.vue", () => { "background-color": "#ff0000", color: "#ffffff", }); - expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("shadow-sm"); + expect(wrapper.vm.outboundBubbleSurfaceClass(chatItem)).toBe("shadow-xs"); expect(wrapper.vm.isThemeOutboundBubble(chatItem)).toBe(false); }); @@ -1053,6 +1149,68 @@ describe("ConversationViewer.vue", () => { expect(conversationGets().length).toBe(countBefore); }); + + it("uses min loaded peer message id for pagination when telemetry-only rows are hidden from the list", async () => { + const deferredResolvers = deferredConversationGet(); + const peerHash = "a".repeat(32); + const wrapper = mountConversationViewer({ + selectedPeer: { destination_hash: peerHash, display_name: "A" }, + }); + await vi.waitFor(() => expect(deferredResolvers.length).toBeGreaterThanOrEqual(1)); + deferredResolvers[0]({ data: { lxmf_messages: [] } }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showTelemetryInChat).toBe(false); + + wrapper.vm.chatItems = [ + { + type: "lxmf_message", + is_outbound: false, + lxmf_message: { + id: 100, + hash: "h100", + source_hash: peerHash, + destination_hash: "my-hash", + content: "", + state: "delivered", + timestamp: 1700000000, + fields: { commands: [{ "0x01": [1, true] }] }, + }, + }, + { + type: "lxmf_message", + is_outbound: false, + lxmf_message: { + id: 200, + hash: "h200", + source_hash: peerHash, + destination_hash: "my-hash", + content: "hello", + state: "delivered", + timestamp: 1700000001, + fields: {}, + }, + }, + ]; + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.selectedPeerChatItems).toHaveLength(1); + expect(wrapper.vm.oldestMessageId).toBe(100); + + axiosMock.get.mockImplementation((url, config) => { + if (url.includes("/lxmf-messages/conversation/")) { + expect(config.params.after_id).toBe(100); + return Promise.resolve({ data: { lxmf_messages: [] } }); + } + if (url.includes("/path")) return Promise.resolve({ data: { path: [] } }); + if (url.includes("/stamp-info")) return Promise.resolve({ data: { stamp_info: {} } }); + if (url.includes("/signal-metrics")) return Promise.resolve({ data: { signal_metrics: {} } }); + if (url.includes("/contacts/check/")) return Promise.resolve({ data: {} }); + return Promise.resolve({ data: {} }); + }); + + await wrapper.vm.loadPrevious(); + }); }); describe("compose draft persistence", () => { diff --git a/tests/frontend/ConversationViewerButtons.test.js b/tests/frontend/ConversationViewerButtons.test.js index 5cad059..fa16c80 100644 --- a/tests/frontend/ConversationViewerButtons.test.js +++ b/tests/frontend/ConversationViewerButtons.test.js @@ -215,6 +215,69 @@ describe("ConversationViewer.vue button interactions", () => { expect(deleteSpy).toHaveBeenCalledWith(chatItem); }); + it("default translate target prefers meshchatx.translateTargetLang from localStorage", async () => { + localStorage.getItem.mockImplementation((k) => { + if (k === "meshchatx.translateTargetLang") { + return "de"; + } + return null; + }); + const wrapper = mountViewer({ + config: { + translator_argos_enabled: true, + translator_libretranslate_enabled: false, + language: "en", + }, + }); + await wrapper.vm.$nextTick(); + wrapper.vm.translatorLanguages = [ + { code: "en", name: "English" }, + { code: "de", name: "German" }, + ]; + expect(wrapper.vm.defaultTranslateTargetForModal()).toBe("de"); + expect(wrapper.vm.defaultBubbleTranslateTargetForModal()).toBe("de"); + }); + + it("openBubbleTranslateFromContextMenu opens the bubble target bar on the next microtask", async () => { + const chatItem = { + type: "lxmf_message", + is_outbound: false, + lxmf_message: { + hash: "msg-tr", + content: "Hello for translate", + state: "delivered", + fields: {}, + }, + }; + const wrapper = mountViewer({ + config: { + translator_argos_enabled: true, + translator_libretranslate_enabled: false, + language: "en", + }, + }); + await wrapper.vm.$nextTick(); + wrapper.vm.hasTranslator = true; + wrapper.vm.translatorLanguages = [ + { code: "en", name: "English" }, + { code: "de", name: "German" }, + ]; + wrapper.vm.messageContextMenu = { + show: true, + x: 0, + y: 0, + chatItem, + justOpened: false, + openedFromBubble: true, + }; + wrapper.vm.openBubbleTranslateFromContextMenu(); + expect(wrapper.vm.translateTargetBarOpen).toBe(false); + expect(wrapper.vm.messageContextMenu.show).toBe(false); + await new Promise((r) => queueMicrotask(r)); + await vi.waitFor(() => expect(wrapper.vm.translateTargetBarOpen).toBe(true), { timeout: 2000 }); + expect(wrapper.vm.translateTargetModalContext).toEqual({ type: "bubble", chatItem }); + }); + it("call button exists and onStartCall is callable", async () => { const wrapper = mountViewer(); expect(typeof wrapper.vm.onStartCall).toBe("function"); @@ -263,34 +326,48 @@ describe("ConversationViewer.vue button interactions", () => { ...navigator, clipboard: { readText }, }); - const wrapper = mountViewer(); - await wrapper.vm.$nextTick(); - const ta = wrapper.find("#message-input").element; - ta.selectionStart = 0; - ta.selectionEnd = 0; - wrapper.vm.newMessageText = ""; + const prevSc = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); + try { + const wrapper = mountViewer(); + await wrapper.vm.$nextTick(); + const ta = wrapper.find("#message-input").element; + ta.selectionStart = 0; + ta.selectionEnd = 0; + wrapper.vm.newMessageText = ""; - const actionButtons = wrapper.findAll(".attachment-action-button"); - await actionButtons[1].trigger("click"); - await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard")); + const actionButtons = wrapper.findAll(".attachment-action-button"); + await actionButtons[1].trigger("click"); + await vi.waitFor(() => expect(wrapper.vm.newMessageText).toBe("from-clipboard")); + } finally { + Object.defineProperty(window, "isSecureContext", { configurable: true, value: prevSc }); + } }); - it("translateMessage replaces text when the translator API succeeds", async () => { + it("applyComposeTranslation posts translate and replaces text", async () => { axiosMock.post.mockImplementation((url) => { if (url.includes("/translator/translate")) { return Promise.resolve({ data: { translated_text: "translated" } }); } return Promise.resolve({ data: {} }); }); - const wrapper = mountViewer(); + const wrapper = mountViewer({ + config: { + translator_argos_enabled: true, + translator_libretranslate_enabled: false, + language: "en", + }, + }); wrapper.vm.newMessageText = "hello"; - await wrapper.vm.translateMessage(); + await wrapper.vm.applyComposeTranslation("de"); expect(wrapper.vm.newMessageText).toBe("translated"); expect(axiosMock.post).toHaveBeenCalledWith( "/api/v1/translator/translate", expect.objectContaining({ text: "hello", - target_lang: "en", + source_lang: "en", + target_lang: "de", + use_argos: true, }) ); }); diff --git a/tests/frontend/Interface.test.js b/tests/frontend/Interface.test.js index 516d98d..6e464ef 100644 --- a/tests/frontend/Interface.test.js +++ b/tests/frontend/Interface.test.js @@ -57,7 +57,7 @@ describe("Interface.vue", () => { expect(contentArea.exists()).toBe(true); }); - it("has break-words on description for long host:port", () => { + it("has word-wrap on description for long host:port", () => { const wrapper = mountInterface({ _name: "RNS Testnet Amsterdam", type: "TCPClientInterface", @@ -65,7 +65,7 @@ describe("Interface.vue", () => { target_port: 4965, }); const desc = wrapper.find(".text-sm.text-gray-600"); - expect(desc.classes()).toContain("break-words"); + expect(desc.classes()).toContain("wrap-break-word"); expect(desc.classes()).toContain("min-w-0"); }); diff --git a/tests/frontend/RNodeFlasherPage.test.js b/tests/frontend/RNodeFlasherPage.test.js index 4fcc9d1..3260e4b 100644 --- a/tests/frontend/RNodeFlasherPage.test.js +++ b/tests/frontend/RNodeFlasherPage.test.js @@ -1,10 +1,31 @@ -import { mount } from "@vue/test-utils"; +import { mount, flushPromises } from "@vue/test-utils"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const { toastError, toastSuccess, toastInfo, toastWarning } = vi.hoisted(() => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), + toastInfo: vi.fn(), + toastWarning: vi.fn(), +})); + +vi.mock("@/js/ToastUtils.js", () => ({ + default: { + error: toastError, + success: toastSuccess, + info: toastInfo, + warning: toastWarning, + }, +})); + import RNodeFlasherPage from "@/components/tools/RNodeFlasherPage.vue"; describe("RNodeFlasherPage.vue", () => { beforeEach(() => { - // Mock global fetch for the latest_release proxy + toastError.mockClear(); + toastSuccess.mockClear(); + toastInfo.mockClear(); + toastWarning.mockClear(); + window.fetch = vi.fn().mockImplementation((url) => { if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) { return Promise.resolve({ @@ -12,7 +33,13 @@ describe("RNodeFlasherPage.vue", () => { json: () => Promise.resolve({ tag_name: "v1.0", - assets: [{ name: "firmware.zip", browser_download_url: "http://example.com/firmware.zip" }], + assets: [ + { + name: "firmware.zip", + browser_download_url: + "https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip", + }, + ], }), }); } @@ -50,6 +77,20 @@ describe("RNodeFlasherPage.vue", () => { expect(wrapper.text()).toContain("1. tools.rnode_flasher.select_device"); }); + it("requests latest_release without a repo query (GitHub default on server)", async () => { + mountRNodeFlasherPage(); + await vi.waitFor(() => { + expect(window.fetch).toHaveBeenCalled(); + }); + const releaseCalls = window.fetch.mock.calls.filter( + (c) => typeof c[0] === "string" && c[0].includes("latest_release") + ); + expect(releaseCalls.length).toBeGreaterThanOrEqual(1); + const u = releaseCalls[0][0]; + expect(u).toBe("/api/v1/tools/rnode/latest_release"); + expect(u).not.toContain("?"); + }); + it("toggles advanced mode", async () => { const wrapper = mountRNodeFlasherPage(); expect(wrapper.vm.showAdvanced).toBe(false); @@ -87,11 +128,137 @@ describe("RNodeFlasherPage.vue", () => { expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe("https://gitea/example.zip"); }); - it("falls back to the gitea releases/latest/download URL when the release lookup failed", () => { + it("falls back to the GitHub releases/latest/download URL when the release lookup failed", () => { const wrapper = mountRNodeFlasherPage(); wrapper.vm.selectedProduct = { firmware_filename: "rnode_firmware_heltec32v3.zip" }; wrapper.vm.latestRelease = null; const url = wrapper.vm._resolveRecommendedAssetUrl(); - expect(url).toMatch(/\/Reticulum\/RNode_Firmware\/releases\/latest\/download\/rnode_firmware_heltec32v3\.zip$/); + expect(url).toBe( + "https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_heltec32v3.zip" + ); + }); + + it("uses model firmware_filename when present for fallback URL", () => { + const wrapper = mountRNodeFlasherPage(); + wrapper.vm.selectedProduct = { models: [] }; + wrapper.vm.selectedModel = { firmware_filename: "rnode_firmware_tbeam.zip" }; + wrapper.vm.latestRelease = null; + expect(wrapper.vm._resolveRecommendedAssetUrl()).toBe( + "https://github.com/markqvist/RNode_Firmware/releases/latest/download/rnode_firmware_tbeam.zip" + ); + }); + + it("links footer firmware and flasher pages to GitHub", () => { + const wrapper = mountRNodeFlasherPage(); + const html = wrapper.html(); + expect(html).toContain('href="https://github.com/markqvist/RNode_Firmware"'); + expect(html).toContain('href="https://github.com/liamcottle/rnode-flasher"'); + }); + + it("downloadRecommendedFirmware requests proxied download with encoded GitHub URL", async () => { + const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00]); + const assetUrl = "https://github.com/markqvist/RNode_Firmware/releases/download/v1.0/firmware.zip"; + + window.fetch = vi.fn().mockImplementation((url) => { + if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tag_name: "v1.0", + assets: [{ name: "firmware.zip", browser_download_url: assetUrl }], + }), + }); + } + if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) { + expect(url).toContain(encodeURIComponent(assetUrl)); + return Promise.resolve({ + ok: true, + blob: () => Promise.resolve(new Blob([zipBytes], { type: "application/zip" })), + }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); + + const wrapper = mount(RNodeFlasherPage, { + global: { + mocks: { + $t: (key, params) => key + (params ? JSON.stringify(params) : ""), + $router: { push: vi.fn() }, + }, + stubs: { + MaterialDesignIcon: { + template: '
', + props: ["iconName"], + }, + "v-icon": true, + "v-progress-circular": true, + "v-progress-linear": true, + RNodeFirmwareSelector: { + name: "RNodeFirmwareSelectorStub", + template: "
", + methods: { setFile: vi.fn() }, + }, + }, + }, + }); + await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull()); + wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" }; + + await wrapper.vm.downloadRecommendedFirmware(); + await flushPromises(); + + expect(toastSuccess).toHaveBeenCalledWith("tools.rnode_flasher.alerts.firmware_downloaded"); + expect(wrapper.vm.firmwareFile).not.toBe(null); + expect(wrapper.vm.firmwareFile.name).toBe("firmware.zip"); + }); + + it("downloadRecommendedFirmware shows error when no firmware filename", async () => { + const wrapper = mountRNodeFlasherPage(); + wrapper.vm.selectedProduct = null; + wrapper.vm.selectedModel = null; + await wrapper.vm.downloadRecommendedFirmware(); + expect(toastError).toHaveBeenCalledWith("tools.rnode_flasher.errors.firmware_not_found_in_release"); + }); + + it("downloadRecommendedFirmware shows error when download fails", async () => { + window.fetch = vi.fn().mockImplementation((url) => { + if (typeof url === "string" && url.includes("/api/v1/tools/rnode/latest_release")) { + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + tag_name: "v1.0", + assets: [ + { + name: "firmware.zip", + browser_download_url: + "https://github.com/markqvist/RNode_Firmware/releases/download/v1/firmware.zip", + }, + ], + }), + }); + } + if (typeof url === "string" && url.includes("/api/v1/tools/rnode/download_firmware")) { + return Promise.resolve({ + ok: false, + status: 502, + statusText: "Bad Gateway", + json: () => Promise.resolve({ error: "upstream broke" }), + }); + } + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + }); + + const wrapper = mountRNodeFlasherPage(); + await vi.waitFor(() => expect(wrapper.vm.latestRelease).not.toBeNull()); + wrapper.vm.selectedProduct = { firmware_filename: "firmware.zip" }; + + await wrapper.vm.downloadRecommendedFirmware(); + + expect(toastError).toHaveBeenCalled(); + const msg = toastError.mock.calls[0][0]; + expect(msg).toContain("tools.rnode_flasher.errors.failed_download"); + expect(msg).toContain("upstream broke"); }); }); diff --git a/tests/frontend/SettingsPage.config-persistence.test.js b/tests/frontend/SettingsPage.config-persistence.test.js index 03bb28a..ee29425 100644 --- a/tests/frontend/SettingsPage.config-persistence.test.js +++ b/tests/frontend/SettingsPage.config-persistence.test.js @@ -91,6 +91,14 @@ describe("SettingsPage — config persistence (PATCH and related)", () => { expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { theme: "light" }); }); + it("onAnnounceStoreToggle PATCHes a single announce_store flag", async () => { + const w = await mountSettingsPage(api); + w.vm.config.announce_store_lxmf_delivery = true; + await w.vm.onAnnounceStoreToggle("announce_store_lxmf_delivery", false); + expect(w.vm.config.announce_store_lxmf_delivery).toBe(false); + expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { announce_store_lxmf_delivery: false }); + }); + it("onLanguageChange PATCHes language", async () => { const w = await mountSettingsPage(api); w.vm.config.language = "de"; @@ -424,18 +432,6 @@ describe("SettingsPage — config persistence (PATCH and related)", () => { expect(router.push).toHaveBeenCalledWith({ name: "auth" }); }); - it("translator toggle and debounced URL PATCH", async () => { - const w = await mountSettingsPage(api); - await w.vm.onTranslatorEnabledChange(true); - expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { translator_enabled: true }); - w.vm.config.libretranslate_url = "http://translate.example"; - await w.vm.onTranslatorConfigChange(); - await vi.advanceTimersByTimeAsync(1000); - expect(api.patch).toHaveBeenCalledWith("/api/v1/config", { - libretranslate_url: "http://translate.example", - }); - }); - it("onGiteaConfigChange PATCHes after debounce", async () => { const w = await mountSettingsPage(api); w.vm.config.gitea_base_url = "https://gitea.example"; diff --git a/tests/frontend/TranslatorPage.test.js b/tests/frontend/TranslatorPage.test.js index 4d2ce01..1195e84 100644 --- a/tests/frontend/TranslatorPage.test.js +++ b/tests/frontend/TranslatorPage.test.js @@ -14,7 +14,15 @@ describe("TranslatorPage.vue", () => { axiosMock.get.mockImplementation((url) => { if (url === "/api/v1/config") { - return Promise.resolve({ data: { config: { translator_enabled: true } } }); + return Promise.resolve({ + data: { + config: { + translator_argos_enabled: true, + translator_libretranslate_enabled: true, + libretranslate_url: "http://localhost:5000", + }, + }, + }); } if (url === "/api/v1/translator/languages") { return Promise.resolve({ @@ -26,6 +34,8 @@ describe("TranslatorPage.vue", () => { { code: "de", name: "German", source: "libretranslate" }, ], has_argos: true, + libre_client_available: true, + libretranslate_reachable: true, }, }); } @@ -61,6 +71,40 @@ describe("TranslatorPage.vue", () => { expect(wrapper.text()).toContain("Translator"); }); + it("shows Libre tab when HTTP client is available even if server is not reachable yet", async () => { + axiosMock.get.mockImplementation((url) => { + if (url === "/api/v1/config") { + return Promise.resolve({ + data: { + config: { + translator_argos_enabled: false, + translator_libretranslate_enabled: false, + libretranslate_url: "http://127.0.0.1:5000", + }, + }, + }); + } + if (url === "/api/v1/translator/languages") { + return Promise.resolve({ + data: { + languages: [], + has_argos: false, + libre_client_available: true, + libretranslate_reachable: false, + }, + }); + } + return Promise.resolve({ data: {} }); + }); + const wrapper = mountTranslatorPage(); + await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); + const libreTab = wrapper.findAll("button").find((b) => b.text().includes("LibreTranslate")); + expect(libreTab).toBeDefined(); + await libreTab.trigger("click"); + expect(wrapper.vm.translationMode).toBe("libretranslate"); + expect(wrapper.text()).toContain("LibreTranslate API Server"); + }); + it("switches translation modes", async () => { const wrapper = mountTranslatorPage(); await vi.waitFor(() => expect(wrapper.vm.config).not.toBeNull()); diff --git a/tests/frontend/clipboardUtils.test.js b/tests/frontend/clipboardUtils.test.js new file mode 100644 index 0000000..9e39246 --- /dev/null +++ b/tests/frontend/clipboardUtils.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + copyTextToClipboard, + readTextFromClipboard, + isWindowSecureContext, +} from "../../meshchatx/src/frontend/js/clipboardUtils.js"; + +describe("clipboardUtils", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("copyTextToClipboard uses execCommand when writeText rejects", async () => { + const writeText = vi.fn(() => Promise.reject(new Error("blocked"))); + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { writeText }, + }); + const prevExec = document.execCommand; + const execCommand = vi.fn(() => true); + document.execCommand = execCommand; + try { + const ok = await copyTextToClipboard("hello"); + expect(ok).toBe(true); + expect(writeText).toHaveBeenCalledWith("hello"); + expect(execCommand).toHaveBeenCalledWith("copy"); + } finally { + document.execCommand = prevExec; + } + }); + + it("readTextFromClipboard returns insecure_context when window.isSecureContext is false (e.g. http://0.0.0.0)", async () => { + const readText = vi.fn(() => Promise.resolve("should-not-run")); + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { readText }, + }); + const prev = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: false, + }); + const result = await readTextFromClipboard(); + expect(result.ok).toBe(false); + expect(result.code).toBe("insecure_context"); + expect(readText).not.toHaveBeenCalled(); + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: prev, + }); + }); + + it("readTextFromClipboard reads when secure and API present", async () => { + const readText = vi.fn(() => Promise.resolve("body")); + vi.stubGlobal("navigator", { + ...navigator, + clipboard: { readText }, + }); + const prev = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: true }); + try { + const result = await readTextFromClipboard(); + expect(result.ok).toBe(true); + expect(result.text).toBe("body"); + expect(readText).toHaveBeenCalled(); + } finally { + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: prev, + }); + } + }); + + it("isWindowSecureContext is false when explicitly false", () => { + const prev = window.isSecureContext; + Object.defineProperty(window, "isSecureContext", { configurable: true, value: false }); + expect(isWindowSecureContext()).toBe(false); + Object.defineProperty(window, "isSecureContext", { configurable: true, value: prev }); + }); +}); diff --git a/tests/frontend/fixtures/settingsPageTestApi.js b/tests/frontend/fixtures/settingsPageTestApi.js index 308f29a..c2bb221 100644 --- a/tests/frontend/fixtures/settingsPageTestApi.js +++ b/tests/frontend/fixtures/settingsPageTestApi.js @@ -88,11 +88,17 @@ export function buildFullServerConfig(overrides = {}) { message_inbound_bubble_color: null, message_failed_bubble_color: "#ef4444", message_waiting_bubble_color: "#e5e7eb", - translator_enabled: false, + translator_argos_enabled: false, + translator_libretranslate_enabled: false, libretranslate_url: "http://localhost:5000", desktop_open_calls_in_separate_window: false, desktop_hardware_acceleration_enabled: true, blackhole_integration_enabled: true, + announce_store_lxmf_delivery: true, + announce_store_lxst_telephony: true, + announce_store_nomadnetwork_node: true, + announce_store_lxmf_propagation: true, + announce_store_git_repositories: true, announce_max_stored_lxmf_delivery: 1000, announce_max_stored_nomadnetwork_node: 1000, announce_max_stored_lxmf_propagation: 1000, diff --git a/tests/frontend/localMessageRetention.test.js b/tests/frontend/localMessageRetention.test.js new file mode 100644 index 0000000..4812418 --- /dev/null +++ b/tests/frontend/localMessageRetention.test.js @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeRetentionValue, + MAX_RETENTION_DAYS, + MAX_RETENTION_MONTHS, +} from "../../meshchatx/src/frontend/js/localMessageRetention.js"; + +describe("localMessageRetention", () => { + it("clamps day values", () => { + expect(normalizeRetentionValue(5, "days")).toEqual({ value: 5, unit: "days" }); + expect(normalizeRetentionValue(99999, "days").value).toBe(MAX_RETENTION_DAYS); + expect(normalizeRetentionValue(0, "days").value).toBe(1); + }); + it("clamps month values", () => { + expect(normalizeRetentionValue(2, "months")).toEqual({ value: 2, unit: "months" }); + expect(normalizeRetentionValue(500, "months").value).toBe(MAX_RETENTION_MONTHS); + }); + it("defaults bad numbers to 1", () => { + expect(normalizeRetentionValue("x", "days")).toEqual({ value: 1, unit: "days" }); + }); +}); diff --git a/tests/frontend/lxmfReactions.test.js b/tests/frontend/lxmfReactions.test.js index 878ee74..7cfdc0a 100644 --- a/tests/frontend/lxmfReactions.test.js +++ b/tests/frontend/lxmfReactions.test.js @@ -50,6 +50,11 @@ describe("lxmfConversationListPreview", () => { }); describe("mergeLxmfReactionRowsIntoMessages", () => { + it("returns an empty array when input is not an array", () => { + expect(mergeLxmfReactionRowsIntoMessages(undefined)).toEqual([]); + expect(mergeLxmfReactionRowsIntoMessages(null)).toEqual([]); + }); + it("merges reaction rows onto parents and drops reaction-only rows", () => { const parentHash = "a".repeat(32); const incoming = [ @@ -77,6 +82,22 @@ describe("mergeLxmfReactionRowsIntoMessages", () => { expect(out[0].reactions[0].sender).toBe("e".repeat(32)); }); + it("matches reaction_to to parent hash case-insensitively", () => { + const parentHash = "Aa".repeat(16); + const incoming = [ + { hash: parentHash, content: "hi", is_reaction: false }, + { + hash: "c".repeat(32), + is_reaction: true, + reaction_to: parentHash.toLowerCase(), + reaction_emoji: "\u{1F44D}", + reaction_sender: "e".repeat(32), + }, + ]; + const out = mergeLxmfReactionRowsIntoMessages(incoming); + expect(out[0].reactions).toHaveLength(1); + }); + it("dedupes same sender and emoji", () => { const parentHash = "a".repeat(32); const sender = "e".repeat(32); diff --git a/tests/frontend/messageDisplayLimits.test.js b/tests/frontend/messageDisplayLimits.test.js new file mode 100644 index 0000000..1124f01 --- /dev/null +++ b/tests/frontend/messageDisplayLimits.test.js @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { + MESSAGE_BODY_MAX_DISPLAY_CHARS, + isStringTooLargeForInlineDisplay, +} from "../../meshchatx/src/frontend/js/messageDisplayLimits.js"; + +describe("messageDisplayLimits", () => { + it("isStringTooLargeForInlineDisplay is false at limit", () => { + const s = "a".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS); + expect(isStringTooLargeForInlineDisplay(s)).toBe(false); + }); + + it("isStringTooLargeForInlineDisplay is true above limit", () => { + const s = "a".repeat(MESSAGE_BODY_MAX_DISPLAY_CHARS + 1); + expect(isStringTooLargeForInlineDisplay(s)).toBe(true); + }); + + it("isStringTooLargeForInlineDisplay is false for non-strings", () => { + expect(isStringTooLargeForInlineDisplay(null)).toBe(false); + expect(isStringTooLargeForInlineDisplay(undefined)).toBe(false); + expect(isStringTooLargeForInlineDisplay(123)).toBe(false); + }); +}); diff --git a/tests/frontend/messageListVirtual.test.js b/tests/frontend/messageListVirtual.test.js index 11773d6..4e21252 100644 --- a/tests/frontend/messageListVirtual.test.js +++ b/tests/frontend/messageListVirtual.test.js @@ -36,6 +36,18 @@ describe("messageListVirtual.js", () => { expect(findDisplayGroupIndexForMessageHash(groups, "missing")).toBe(-1); }); + it("findDisplayGroupIndexForMessageHash skips date dividers", () => { + const groups = [ + { type: "dateDivider", dayKey: "2026-04-26", key: "d1" }, + { type: "single", key: "x", chatItem: { lxmf_message: { hash: "h1" } } }, + ]; + expect(findDisplayGroupIndexForMessageHash(groups, "h1")).toBe(1); + }); + + it("estimateGroupHeight returns modest height for date dividers", () => { + expect(estimateGroupHeight({ type: "dateDivider", key: "d" })).toBe(44); + }); + it("MIN_VIRTUAL_DISPLAY_GROUPS is a positive threshold", () => { expect(MIN_VIRTUAL_DISPLAY_GROUPS).toBeGreaterThan(10); }); diff --git a/tests/frontend/messageTimestampGrouping.test.js b/tests/frontend/messageTimestampGrouping.test.js new file mode 100644 index 0000000..1a06654 --- /dev/null +++ b/tests/frontend/messageTimestampGrouping.test.js @@ -0,0 +1,111 @@ +import { describe, it, expect } from "vitest"; +import { + TIMESTAMP_CLUSTER_GAP_MS, + buildTimestampGroupedOldestFirst, + calendarDayKeyFromDate, + displayGroupIsOutbound, + displayGroupSortBoundsMs, +} from "../../meshchatx/src/frontend/js/messageTimestampGrouping.js"; + +describe("messageTimestampGrouping", () => { + it("calendarDayKeyFromDate uses local calendar fields", () => { + const d = new Date(2026, 3, 26, 23, 59); + expect(calendarDayKeyFromDate(d)).toBe("2026-04-26"); + }); + + it("inserts a date divider when calendar day changes", () => { + const a = { + type: "single", + key: "a", + chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-25T12:00:00Z", hash: "a" } }, + }; + const b = { + type: "single", + key: "b", + chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-28T12:00:00Z", hash: "b" } }, + }; + const out = buildTimestampGroupedOldestFirst([a, b]); + const dividers = out.filter((x) => x.type === "dateDivider"); + expect(dividers.length).toBeGreaterThanOrEqual(2); + expect( + out + .filter((x) => x.type === "single") + .map((x) => x.key) + .join(",") + ).toBe("a,b"); + }); + + it("hides timestamp on middle messages of a same-side cluster within gap", () => { + const t0 = "2026-04-26T12:00:00Z"; + const t1 = "2026-04-26T12:01:00Z"; + const t2 = "2026-04-26T12:02:00Z"; + const m0 = { + type: "single", + key: "m0", + chatItem: { is_outbound: true, lxmf_message: { created_at: t0, hash: "h0" } }, + }; + const m1 = { + type: "single", + key: "m1", + chatItem: { is_outbound: true, lxmf_message: { created_at: t1, hash: "h1" } }, + }; + const m2 = { + type: "single", + key: "m2", + chatItem: { is_outbound: true, lxmf_message: { created_at: t2, hash: "h2" } }, + }; + const out = buildTimestampGroupedOldestFirst([m0, m1, m2]).filter((x) => x.type === "single"); + expect(out[0].showTimestamp).toBe(false); + expect(out[1].showTimestamp).toBe(false); + expect(out[2].showTimestamp).toBe(true); + }); + + it("starts a new cluster after TIMESTAMP_CLUSTER_GAP_MS", () => { + const t0 = "2026-04-26T12:00:00Z"; + const t1 = new Date(new Date(t0).getTime() + TIMESTAMP_CLUSTER_GAP_MS + 60 * 1000).toISOString(); + const a = { + type: "single", + key: "a", + chatItem: { is_outbound: true, lxmf_message: { created_at: t0, hash: "a" } }, + }; + const b = { + type: "single", + key: "b", + chatItem: { is_outbound: true, lxmf_message: { created_at: t1, hash: "b" } }, + }; + const out = buildTimestampGroupedOldestFirst([a, b]).filter((x) => x.type === "single"); + expect(out[0].showTimestamp).toBe(true); + expect(out[1].showTimestamp).toBe(true); + }); + + it("with grouping disabled, omits date dividers and shows timestamp on every message", () => { + const a = { + type: "single", + key: "a", + chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-25T12:00:00Z", hash: "a" } }, + }; + const b = { + type: "single", + key: "b", + chatItem: { is_outbound: false, lxmf_message: { created_at: "2026-04-28T12:00:00Z", hash: "b" } }, + }; + const out = buildTimestampGroupedOldestFirst([a, b], { groupingEnabled: false }); + expect(out.filter((x) => x.type === "dateDivider")).toHaveLength(0); + const singles = out.filter((x) => x.type === "single"); + expect(singles.every((x) => x.showTimestamp === true)).toBe(true); + }); + + it("displayGroupSortBoundsMs spans image group min/max", () => { + const g = { + type: "imageGroup", + key: "ig", + items: [ + { is_outbound: true, lxmf_message: { created_at: "2026-04-26T12:00:00Z" } }, + { is_outbound: true, lxmf_message: { created_at: "2026-04-26T12:05:00Z" } }, + ], + }; + const b = displayGroupSortBoundsMs(g); + expect(b.max - b.min).toBe(5 * 60 * 1000); + expect(displayGroupIsOutbound(g)).toBe(true); + }); +}); diff --git a/tests/frontend/reticulumPathfinding.test.js b/tests/frontend/reticulumPathfinding.test.js new file mode 100644 index 0000000..21514bc --- /dev/null +++ b/tests/frontend/reticulumPathfinding.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from "vitest"; +import { + getDestinationPath, + postRequestPath, + postDropPath, + runDestinationPathFinder, +} from "../../meshchatx/src/frontend/js/reticulumPathfinding.js"; + +describe("reticulumPathfinding", () => { + it("getDestinationPath uses destination path API", async () => { + const api = { get: vi.fn().mockResolvedValue({ data: { path: null } }) }; + await getDestinationPath(api, "abcd", { request: "1", timeout: 4 }); + expect(api.get).toHaveBeenCalledWith("/api/v1/destination/abcd/path", { + params: { request: "1", timeout: 4 }, + }); + }); + + it("coerces request true to string", async () => { + const api = { get: vi.fn().mockResolvedValue({ data: {} }) }; + await getDestinationPath(api, "h1", { request: true }); + expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h1/path", { + params: { request: "1" }, + }); + }); + + it("coerces request false to string", async () => { + const api = { get: vi.fn().mockResolvedValue({ data: {} }) }; + await getDestinationPath(api, "h2", { request: false }); + expect(api.get).toHaveBeenCalledWith("/api/v1/destination/h2/path", { + params: { request: "0" }, + }); + }); + + it("postRequestPath and postDropPath hit expected routes", async () => { + const api = { post: vi.fn().mockResolvedValue({ data: {} }) }; + await postRequestPath(api, "aaaabbbbccccddddeeeeffffaaaabbbb"); + expect(api.post).toHaveBeenCalledWith("/api/v1/destination/aaaabbbbccccddddeeeeffffaaaabbbb/request-path"); + await postDropPath(api, "x"); + expect(api.post).toHaveBeenCalledWith("/api/v1/destination/x/drop-path"); + }); + + it("runDestinationPathFinder quick posts request-path", async () => { + const api = { post: vi.fn().mockResolvedValue({ data: {} }) }; + const r = await runDestinationPathFinder(api, "q1", "quick"); + expect(r.ok).toBe(true); + expect(api.post).toHaveBeenCalledWith("/api/v1/destination/q1/request-path"); + }); + + it("runDestinationPathFinder force uses GET with wait", async () => { + const api = { + get: vi.fn().mockResolvedValue({ data: { path: { hops: 1 } } }), + }; + const r = await runDestinationPathFinder(api, "f1", "force", { forceTimeout: 9 }); + expect(r.path.hops).toBe(1); + expect(api.get).toHaveBeenCalledWith("/api/v1/destination/f1/path", { + params: { request: "1", timeout: 9 }, + }); + }); + + it("runDestinationPathFinder drop_then_request drops then posts", async () => { + const api = { post: vi.fn().mockResolvedValue({ data: {} }) }; + await runDestinationPathFinder(api, "d1", "drop_then_request"); + expect(api.post).toHaveBeenNthCalledWith(1, "/api/v1/destination/d1/drop-path"); + expect(api.post).toHaveBeenNthCalledWith(2, "/api/v1/destination/d1/request-path"); + }); + + it("runDestinationPathFinder drop_then_request continues if drop fails with handler", async () => { + const onDrop = vi.fn(); + const api = { + post: vi.fn().mockRejectedValueOnce(new Error("no drop")).mockResolvedValue({ data: {} }), + }; + await runDestinationPathFinder(api, "d2", "drop_then_request", { onDropPathError: onDrop }); + expect(onDrop).toHaveBeenCalled(); + expect(api.post).toHaveBeenLastCalledWith("/api/v1/destination/d2/request-path"); + }); + + it("runDestinationPathFinder rejects unknown mode", async () => { + const api = { get: vi.fn(), post: vi.fn() }; + await expect(runDestinationPathFinder(api, "z", "invalid")).rejects.toThrow("unknown path finder mode"); + }); +}); diff --git a/tests/frontend/settingsSearchUtils.test.js b/tests/frontend/settingsSearchUtils.test.js new file mode 100644 index 0000000..3735bbe --- /dev/null +++ b/tests/frontend/settingsSearchUtils.test.js @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: 0BSD AND MIT + +import { describe, it, expect } from "vitest"; +import { + foldForSearch, + matchesSettingSearch, + normalizeSearchString, + tokenizeSettingsQuery, +} from "../../meshchatx/src/frontend/js/settingsSearchUtils.js"; + +const t = (key) => { + const map = { + "app.theme": "Theme", + "app.dark_theme": "Dark mode", + "app.stranger_protection": "Stranger protection", + }; + return map[key] ?? key; +}; + +describe("settingsSearchUtils", () => { + it("normalizeSearchString trims and strips zero-width", () => { + expect(normalizeSearchString(" foo\u200b ")).toBe("foo"); + expect(normalizeSearchString("\uFEFF")).toBe(""); + }); + + it("tokenizeSettingsQuery splits on whitespace", () => { + expect(tokenizeSettingsQuery("dark theme")).toEqual(["dark", "theme"]); + }); + + it("foldForSearch removes combining marks", () => { + expect(foldForSearch("Café")).toBe("cafe"); + }); + + it("matchesSettingSearch: empty query matches", () => { + expect(matchesSettingSearch(["app.theme"], t, "")).toBe(true); + expect(matchesSettingSearch(["app.theme"], t, " ")).toBe(true); + }); + + it("matchesSettingSearch: single token substring", () => { + expect(matchesSettingSearch(["app.theme", "app.dark_theme"], t, "dark")).toBe(true); + expect(matchesSettingSearch(["app.theme"], t, "zzz")).toBe(false); + }); + + it("matchesSettingSearch: all tokens must match (AND)", () => { + expect(matchesSettingSearch(["app.stranger_protection", "block"], t, "stranger block")).toBe(true); + expect(matchesSettingSearch(["app.stranger_protection"], t, "stranger block")).toBe(false); + }); + + it("matchesSettingSearch: resolves i18n keys with dots", () => { + expect(matchesSettingSearch(["app.theme"], t, "Theme")).toBe(true); + }); +});