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: "