From 00b4290735a4eb860ec7a8418d27b589b2dd146a Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Fri, 2 Jan 2026 19:41:05 -0600 Subject: [PATCH] feat(tests): add comprehensive test suite for backend functionality, including database, configuration, and telemetry utilities --- tests/__init__.py | 0 tests/backend/__init__.py | 0 tests/backend/test_colour_utils.py | 20 ++ tests/backend/test_config_manager.py | 64 +++++ tests/backend/test_database.py | 109 ++++++++ tests/backend/test_interface_config_parser.py | 58 ++++ tests/backend/test_interface_editor.py | 25 ++ tests/backend/test_lxmf_message_fields.py | 33 +++ tests/backend/test_meshchat_utils.py | 97 +++++++ tests/backend/test_rns_lifecycle.py | 261 ++++++++++++++++++ tests/backend/test_telemetry_utils.py | 43 +++ tests/frontend/IconButton.test.js | 20 ++ tests/frontend/InterfacesPage.test.js | 110 ++++++++ tests/frontend/MaterialDesignIcon.test.js | 27 ++ tests/frontend/Toggle.test.js | 28 ++ 15 files changed, 895 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/backend/__init__.py create mode 100644 tests/backend/test_colour_utils.py create mode 100644 tests/backend/test_config_manager.py create mode 100644 tests/backend/test_database.py create mode 100644 tests/backend/test_interface_config_parser.py create mode 100644 tests/backend/test_interface_editor.py create mode 100644 tests/backend/test_lxmf_message_fields.py create mode 100644 tests/backend/test_meshchat_utils.py create mode 100644 tests/backend/test_rns_lifecycle.py create mode 100644 tests/backend/test_telemetry_utils.py create mode 100644 tests/frontend/IconButton.test.js create mode 100644 tests/frontend/InterfacesPage.test.js create mode 100644 tests/frontend/MaterialDesignIcon.test.js create mode 100644 tests/frontend/Toggle.test.js diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/backend/__init__.py b/tests/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/backend/test_colour_utils.py b/tests/backend/test_colour_utils.py new file mode 100644 index 0000000..9f45fa1 --- /dev/null +++ b/tests/backend/test_colour_utils.py @@ -0,0 +1,20 @@ +import pytest +from meshchatx.src.backend.colour_utils import ColourUtils + + +def test_hex_colour_to_byte_array(): + # Test with # prefix + hex_val = "#FF00AA" + expected = bytes.fromhex("FF00AA") + assert ColourUtils.hex_colour_to_byte_array(hex_val) == expected + + # Test without # prefix + hex_val = "00BBFF" + expected = bytes.fromhex("00BBFF") + assert ColourUtils.hex_colour_to_byte_array(hex_val) == expected + + +def test_hex_colour_to_byte_array_invalid(): + # Test with invalid hex + with pytest.raises(ValueError): + ColourUtils.hex_colour_to_byte_array("#GG00AA") diff --git a/tests/backend/test_config_manager.py b/tests/backend/test_config_manager.py new file mode 100644 index 0000000..b5ffafd --- /dev/null +++ b/tests/backend/test_config_manager.py @@ -0,0 +1,64 @@ +import os +import tempfile +import pytest +from meshchatx.src.backend.database import Database +from meshchatx.src.backend.config_manager import ConfigManager + + +@pytest.fixture +def db(): + fd, path = tempfile.mkstemp() + os.close(fd) + database = Database(path) + database.initialize() + yield database + database.close() + if os.path.exists(path): + os.remove(path) + + +def test_config_manager_get_default(db): + config = ConfigManager(db) + assert config.display_name.get() == "Anonymous Peer" + assert config.theme.get() == "light" + assert config.lxmf_inbound_stamp_cost.get() == 8 + + +def test_config_manager_set_get(db): + config = ConfigManager(db) + config.display_name.set("Test User") + assert config.display_name.get() == "Test User" + + config.lxmf_inbound_stamp_cost.set(20) + assert config.lxmf_inbound_stamp_cost.get() == 20 + + config.auto_announce_enabled.set(True) + assert config.auto_announce_enabled.get() is True + + +def test_config_manager_persistence(db): + config = ConfigManager(db) + config.display_name.set("Persistent User") + + # New manager instance with same DB + config2 = ConfigManager(db) + assert config2.display_name.get() == "Persistent User" + + +def test_config_manager_type_safety(db): + config = ConfigManager(db) + + # IntConfig + config.lxmf_inbound_stamp_cost.set( + "15" + ) # Should handle string to int if implementation allows or just store it + # Looking at implementation might be better, but let's test basic set/get + config.lxmf_inbound_stamp_cost.set(15) + assert isinstance(config.lxmf_inbound_stamp_cost.get(), int) + assert config.lxmf_inbound_stamp_cost.get() == 15 + + # BoolConfig + config.auto_announce_enabled.set(True) + assert config.auto_announce_enabled.get() is True + config.auto_announce_enabled.set(False) + assert config.auto_announce_enabled.get() is False diff --git a/tests/backend/test_database.py b/tests/backend/test_database.py new file mode 100644 index 0000000..836ab72 --- /dev/null +++ b/tests/backend/test_database.py @@ -0,0 +1,109 @@ +import os +import sqlite3 +import tempfile +import pytest +from meshchatx.src.backend.database.provider import DatabaseProvider +from meshchatx.src.backend.database.schema import DatabaseSchema +from meshchatx.src.backend.database.legacy_migrator import LegacyMigrator + + +@pytest.fixture +def temp_db(): + fd, path = tempfile.mkstemp() + os.close(fd) + yield path + if os.path.exists(path): + os.remove(path) + + +def test_database_initialization(temp_db): + provider = DatabaseProvider(temp_db) + schema = DatabaseSchema(provider) + schema.initialize() + + # Check if tables were created + tables = provider.fetchall("SELECT name FROM sqlite_master WHERE type='table'") + table_names = [row["name"] for row in tables] + + assert "config" in table_names + assert "lxmf_messages" in table_names + assert "announces" in table_names + + # Check version + version_row = provider.fetchone( + "SELECT value FROM config WHERE key = 'database_version'" + ) + assert int(version_row["value"]) == DatabaseSchema.LATEST_VERSION + + provider.close() + + +def test_legacy_migrator_detection(temp_db): + # Setup current DB + provider = DatabaseProvider(temp_db) + schema = DatabaseSchema(provider) + schema.initialize() + + # Setup a "legacy" DB in a temp directory + with tempfile.TemporaryDirectory() as legacy_dir: + identity_hash = "deadbeef" + legacy_identity_dir = os.path.join(legacy_dir, "identities", identity_hash) + os.makedirs(legacy_identity_dir) + legacy_db_path = os.path.join(legacy_identity_dir, "database.db") + + legacy_conn = sqlite3.connect(legacy_db_path) + legacy_conn.execute("CREATE TABLE config (key TEXT, value TEXT)") + legacy_conn.execute( + "INSERT INTO config (key, value) VALUES ('display_name', 'Legacy User')" + ) + legacy_conn.commit() + legacy_conn.close() + + migrator = LegacyMigrator(provider, legacy_dir, identity_hash) + assert migrator.get_legacy_db_path() == legacy_db_path + assert migrator.should_migrate() is True + + provider.close() + + +def test_legacy_migration_data(temp_db): + provider = DatabaseProvider(temp_db) + schema = DatabaseSchema(provider) + schema.initialize() + + with tempfile.TemporaryDirectory() as legacy_dir: + identity_hash = "deadbeef" + legacy_identity_dir = os.path.join(legacy_dir, "identities", identity_hash) + os.makedirs(legacy_identity_dir) + legacy_db_path = os.path.join(legacy_identity_dir, "database.db") + + # Create legacy DB with some data + legacy_conn = sqlite3.connect(legacy_db_path) + legacy_conn.execute( + "CREATE TABLE lxmf_messages (hash TEXT UNIQUE, content TEXT)" + ) + legacy_conn.execute( + "INSERT INTO lxmf_messages (hash, content) VALUES ('msg1', 'Hello Legacy')" + ) + legacy_conn.execute("CREATE TABLE config (key TEXT UNIQUE, value TEXT)") + legacy_conn.execute( + "INSERT INTO config (key, value) VALUES ('test_key', 'test_val')" + ) + legacy_conn.commit() + legacy_conn.close() + + migrator = LegacyMigrator(provider, legacy_dir, identity_hash) + assert migrator.migrate() is True + + # Verify data moved + msg_row = provider.fetchone( + "SELECT content FROM lxmf_messages WHERE hash = 'msg1'" + ) + assert msg_row["content"] == "Hello Legacy" + + config_row = provider.fetchone( + "SELECT value FROM config WHERE key = 'test_key'" + ) + assert config_row["value"] == "test_val" + + provider.close() diff --git a/tests/backend/test_interface_config_parser.py b/tests/backend/test_interface_config_parser.py new file mode 100644 index 0000000..eb34f05 --- /dev/null +++ b/tests/backend/test_interface_config_parser.py @@ -0,0 +1,58 @@ +from meshchatx.src.backend.interface_config_parser import InterfaceConfigParser + + +def test_parse_simple_interface(): + config_text = """ +[[Test Interface]] + type = TCPClientInterface + enabled = yes + target_host = 127.0.0.1 + target_port = 4242 +""" + interfaces = InterfaceConfigParser.parse(config_text) + assert len(interfaces) == 1 + assert interfaces[0]["name"] == "Test Interface" + assert interfaces[0]["type"] == "TCPClientInterface" + assert interfaces[0]["enabled"] == "yes" + + +def test_parse_multiple_interfaces(): + config_text = """ +[[Interface One]] + type = RNodeInterface +[[Interface Two]] + type = TCPClientInterface +""" + interfaces = InterfaceConfigParser.parse(config_text) + assert len(interfaces) == 2 + assert interfaces[0]["name"] == "Interface One" + assert interfaces[1]["name"] == "Interface Two" + + +def test_parse_best_effort_on_failure(): + # Invalid config that should trigger best-effort parsing + config_text = """ +[[Broken Interface] + type = something +[[Fixed Interface]] + type = fixed +""" + # Note: ConfigObj might still parse [[Broken Interface] as a key if not careful, + # but the parser should return something. + interfaces = InterfaceConfigParser.parse(config_text) + assert len(interfaces) >= 1 + names = [i["name"] for i in interfaces] + assert "Fixed Interface" in names + + +def test_parse_subsections(): + config_text = """ +[[Interface With Sub]] + type = AutoInterface + [[[sub_config]]] + key = value +""" + interfaces = InterfaceConfigParser.parse(config_text) + assert len(interfaces) == 1 + assert "sub_config" in interfaces[0] + assert interfaces[0]["sub_config"]["key"] == "value" diff --git a/tests/backend/test_interface_editor.py b/tests/backend/test_interface_editor.py new file mode 100644 index 0000000..abe03eb --- /dev/null +++ b/tests/backend/test_interface_editor.py @@ -0,0 +1,25 @@ +from meshchatx.src.backend.interface_editor import InterfaceEditor + + +def test_update_value_add(): + details = {"type": "TCPClientInterface"} + InterfaceEditor.update_value(details, {"host": "1.2.3.4"}, "host") + assert details["host"] == "1.2.3.4" + + +def test_update_value_update(): + details = {"host": "1.2.3.4"} + InterfaceEditor.update_value(details, {"host": "8.8.8.8"}, "host") + assert details["host"] == "8.8.8.8" + + +def test_update_value_remove_on_none(): + details = {"host": "1.2.3.4"} + InterfaceEditor.update_value(details, {"host": None}, "host") + assert "host" not in details + + +def test_update_value_remove_on_empty_string(): + details = {"host": "1.2.3.4"} + InterfaceEditor.update_value(details, {"host": ""}, "host") + assert "host" not in details diff --git a/tests/backend/test_lxmf_message_fields.py b/tests/backend/test_lxmf_message_fields.py new file mode 100644 index 0000000..1daf15e --- /dev/null +++ b/tests/backend/test_lxmf_message_fields.py @@ -0,0 +1,33 @@ +from meshchatx.src.backend.lxmf_message_fields import ( + LxmfAudioField, + LxmfImageField, + LxmfFileAttachment, + LxmfFileAttachmentsField, +) + + +def test_lxmf_audio_field(): + audio = LxmfAudioField(1, b"audio_data") + assert audio.audio_mode == 1 + assert audio.audio_bytes == b"audio_data" + + +def test_lxmf_image_field(): + image = LxmfImageField("png", b"image_data") + assert image.image_type == "png" + assert image.image_bytes == b"image_data" + + +def test_lxmf_file_attachment(): + file = LxmfFileAttachment("test.txt", b"file_data") + assert file.file_name == "test.txt" + assert file.file_bytes == b"file_data" + + +def test_lxmf_file_attachments_field(): + file1 = LxmfFileAttachment("1.txt", b"data1") + file2 = LxmfFileAttachment("2.txt", b"data2") + field = LxmfFileAttachmentsField([file1, file2]) + assert len(field.file_attachments) == 2 + assert field.file_attachments[0].file_name == "1.txt" + assert field.file_attachments[1].file_name == "2.txt" diff --git a/tests/backend/test_meshchat_utils.py b/tests/backend/test_meshchat_utils.py new file mode 100644 index 0000000..99dea00 --- /dev/null +++ b/tests/backend/test_meshchat_utils.py @@ -0,0 +1,97 @@ +import pytest +import os +import shutil +import tempfile +from unittest.mock import MagicMock, patch +from meshchatx.meshchat import ReticulumMeshChat + + +@pytest.fixture +def temp_dir(): + dir_path = tempfile.mkdtemp() + yield dir_path + if os.path.exists(dir_path): + shutil.rmtree(dir_path) + + +@pytest.fixture +def mock_app(temp_dir): + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + patch("RNS.Identity") as mock_identity, + patch("RNS.Reticulum"), + patch("RNS.Transport"), + patch("threading.Thread"), + ): + mock_id = MagicMock() + # Use a real bytes object for hash so .hex() works naturally + mock_id.hash = b"test_hash_32_bytes_long_01234567" + mock_id.get_private_key.return_value = b"test_private_key" + mock_identity.return_value = mock_id + + app = ReticulumMeshChat( + identity=mock_id, + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + return app + + +def test_get_interfaces_snapshot(mock_app): + # Setup mock reticulum config + mock_reticulum = MagicMock() + mock_reticulum.config = { + "interfaces": { + "Iface1": {"type": "TCP", "enabled": "yes"}, + "Iface2": {"type": "RNode", "enabled": "no"}, + } + } + mock_app.reticulum = mock_reticulum + + snapshot = mock_app._get_interfaces_snapshot() + + assert len(snapshot) == 2 + assert snapshot["Iface1"]["type"] == "TCP" + assert snapshot["Iface2"]["enabled"] == "no" + # Ensure it's a deep copy (not the same object) + assert snapshot["Iface1"] is not mock_reticulum.config["interfaces"]["Iface1"] + + +def test_write_reticulum_config_success(mock_app): + mock_reticulum = MagicMock() + mock_app.reticulum = mock_reticulum + + result = mock_app._write_reticulum_config() + + assert result is True + mock_reticulum.config.write.assert_called_once() + + +def test_write_reticulum_config_no_reticulum(mock_app): + if hasattr(mock_app, "reticulum"): + del mock_app.reticulum + + result = mock_app._write_reticulum_config() + assert result is False + + +def test_write_reticulum_config_failure(mock_app): + mock_reticulum = MagicMock() + mock_reticulum.config.write.side_effect = Exception("Write failed") + mock_app.reticulum = mock_reticulum + + result = mock_app._write_reticulum_config() + assert result is False diff --git a/tests/backend/test_rns_lifecycle.py b/tests/backend/test_rns_lifecycle.py new file mode 100644 index 0000000..2ce0b9c --- /dev/null +++ b/tests/backend/test_rns_lifecycle.py @@ -0,0 +1,261 @@ +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +import os +import shutil +import tempfile +from meshchatx.meshchat import ReticulumMeshChat + + +@pytest.fixture +def mock_rns(): + with ( + patch("RNS.Reticulum") as mock_reticulum, + patch("RNS.Transport") as mock_transport, + patch("RNS.Identity") as mock_identity, + patch("threading.Thread"), + ): + # Setup mock identity + mock_id_instance = MagicMock() + # Use a real bytes object for hash so .hex() works naturally + mock_id_instance.hash = b"test_hash_32_bytes_long_01234567" + mock_id_instance.get_private_key.return_value = b"test_private_key" + mock_identity.return_value = mock_id_instance + mock_identity.from_file.return_value = mock_id_instance + + # Setup mock transport + mock_transport.interfaces = [] + mock_transport.destinations = [] + mock_transport.active_links = [] + mock_transport.announce_handlers = [] + + yield { + "Reticulum": mock_reticulum, + "Transport": mock_transport, + "Identity": mock_identity, + "id_instance": mock_id_instance, + } + + +@pytest.fixture +def temp_dir(): + dir_path = tempfile.mkdtemp() + yield dir_path + shutil.rmtree(dir_path) + + +@pytest.mark.asyncio +async def test_cleanup_rns_state_for_identity(mock_rns, temp_dir): + # Mock database and other managers to avoid heavy initialization + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + ): + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + # Create a mock destination that should be cleaned up + mock_dest = MagicMock() + mock_dest.identity = mock_rns["id_instance"] + mock_rns["Transport"].destinations = [mock_dest] + + # Create a mock link that should be cleaned up + mock_link = MagicMock() + mock_link.destination = mock_dest + mock_rns["Transport"].active_links = [mock_link] + + app.cleanup_rns_state_for_identity(mock_rns["id_instance"].hash) + + # Verify deregistration and teardown were called + mock_rns["Transport"].deregister_destination.assert_called_with(mock_dest) + mock_link.teardown.assert_called() + + +@pytest.mark.asyncio +async def test_teardown_identity(mock_rns, temp_dir): + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + ): + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + # Add some mock handlers to check deregistration + mock_handler = MagicMock() + mock_handler.aspect_filter = "test" + mock_rns["Transport"].announce_handlers = [mock_handler] + + app.teardown_identity() + + assert app.running is False + mock_rns["Transport"].deregister_announce_handler.assert_called_with( + mock_handler + ) + app.database.close.assert_called() + + +@pytest.mark.asyncio +async def test_reload_reticulum(mock_rns, temp_dir): + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + patch("asyncio.sleep", return_value=None), + patch("socket.socket") as mock_socket, + ): + # Mock socket to simulate port 37429 becoming free immediately + mock_sock_inst = MagicMock() + mock_socket.return_value = mock_sock_inst + + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + # Re-mock setup_identity to avoid multiple background thread starts during test + app.setup_identity = MagicMock() + + result = await app.reload_reticulum() + + assert result is True + mock_rns["Reticulum"].exit_handler.assert_called() + # Verify RNS singleton was cleared (via private attribute access in code) + assert mock_rns["Reticulum"]._Reticulum__instance is None + # Verify setup_identity was called again + app.setup_identity.assert_called() + + +@pytest.mark.asyncio +async def test_reload_reticulum_failure_recovery(mock_rns, temp_dir): + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + patch("asyncio.sleep", return_value=None), + patch("socket.socket"), + ): + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + # Re-mock setup_identity to avoid multiple background thread starts and to check calls + app.setup_identity = MagicMock() + + # Simulate a failure during reload AFTER reticulum was deleted + if hasattr(app, "reticulum"): + del app.reticulum + + # We need to make something else fail to reach the except block + # or just mock a method inside the try block to raise. + with patch.object( + app, "teardown_identity", side_effect=Exception("Reload failed") + ): + result = await app.reload_reticulum() + + assert result is False + # Verify recovery: setup_identity should be called because hasattr(self, "reticulum") is False + app.setup_identity.assert_called() + + +@pytest.mark.asyncio +async def test_hotswap_identity(mock_rns, temp_dir): + with ( + patch("meshchatx.meshchat.Database"), + patch("meshchatx.meshchat.ConfigManager"), + patch("meshchatx.meshchat.MessageHandler"), + patch("meshchatx.meshchat.AnnounceManager"), + patch("meshchatx.meshchat.ArchiverManager"), + patch("meshchatx.meshchat.MapManager"), + patch("meshchatx.meshchat.TelephoneManager"), + patch("meshchatx.meshchat.VoicemailManager"), + patch("meshchatx.meshchat.RingtoneManager"), + patch("meshchatx.meshchat.RNCPHandler"), + patch("meshchatx.meshchat.RNStatusHandler"), + patch("meshchatx.meshchat.RNProbeHandler"), + patch("meshchatx.meshchat.TranslatorHandler"), + patch("LXMF.LXMRouter"), + patch("asyncio.sleep", return_value=None), + patch("shutil.copy2"), + ): + app = ReticulumMeshChat( + identity=mock_rns["id_instance"], + storage_dir=temp_dir, + reticulum_config_dir=temp_dir, + ) + + # Create a mock identity file for the new identity + new_identity_hash = "new_hash" + new_identity_dir = os.path.join(temp_dir, "identities", new_identity_hash) + os.makedirs(new_identity_dir) + with open(os.path.join(new_identity_dir, "identity"), "wb") as f: + f.write(b"new_identity_data") + + app.reload_reticulum = AsyncMock(return_value=True) + app.websocket_broadcast = AsyncMock() + + # Mock config to avoid JSON serialization error of MagicMocks + app.config = MagicMock() + app.config.display_name.get.return_value = "Test User" + + result = await app.hotswap_identity(new_identity_hash) + + assert result is True + app.reload_reticulum.assert_called() + app.websocket_broadcast.assert_called() + # Check if the broadcast contains identity_switched + broadcast_call = app.websocket_broadcast.call_args[0][0] + assert "identity_switched" in broadcast_call diff --git a/tests/backend/test_telemetry_utils.py b/tests/backend/test_telemetry_utils.py new file mode 100644 index 0000000..7d513f1 --- /dev/null +++ b/tests/backend/test_telemetry_utils.py @@ -0,0 +1,43 @@ +import time +from meshchatx.src.backend.telemetry_utils import Telemeter + + +def test_pack_unpack_location(): + lat, lon = 52.5200, 13.4050 + alt, speed, bearing, acc = 100.5, 5.25, 180, 2.5 + ts = int(time.time()) + + packed = Telemeter.pack_location(lat, lon, alt, speed, bearing, acc, ts) + + assert packed is not None + assert len(packed) == 7 + + unpacked = Telemeter.unpack_location(packed) + + assert unpacked["latitude"] == lat + assert unpacked["longitude"] == lon + assert unpacked["altitude"] == alt + assert unpacked["speed"] == speed + assert unpacked["bearing"] == bearing + assert unpacked["accuracy"] == acc + assert unpacked["last_update"] == ts + + +def test_pack_unpack_full_telemetry(): + location = { + "latitude": 40.7128, + "longitude": -74.0060, + "altitude": 10.0, + "speed": 0.0, + "bearing": 0, + "accuracy": 5.0, + } + ts = int(time.time()) + + packed_blob = Telemeter.pack(time_utc=ts, location=location) + assert isinstance(packed_blob, bytes) + + unpacked = Telemeter.from_packed(packed_blob) + assert unpacked["time"]["utc"] == ts + assert unpacked["location"]["latitude"] == location["latitude"] + assert unpacked["location"]["longitude"] == location["longitude"] diff --git a/tests/frontend/IconButton.test.js b/tests/frontend/IconButton.test.js new file mode 100644 index 0000000..d3a696c --- /dev/null +++ b/tests/frontend/IconButton.test.js @@ -0,0 +1,20 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import IconButton from "../../meshchatx/src/frontend/components/IconButton.vue"; + +describe("IconButton.vue", () => { + it("renders slot content", () => { + const wrapper = mount(IconButton, { + slots: { + default: 'icon', + }, + }); + expect(wrapper.find(".test-icon").exists()).toBe(true); + expect(wrapper.text()).toBe("icon"); + }); + + it("has correct button type", () => { + const wrapper = mount(IconButton); + expect(wrapper.attributes("type")).toBe("button"); + }); +}); diff --git a/tests/frontend/InterfacesPage.test.js b/tests/frontend/InterfacesPage.test.js new file mode 100644 index 0000000..70ab501 --- /dev/null +++ b/tests/frontend/InterfacesPage.test.js @@ -0,0 +1,110 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import InterfacesPage from "../../meshchatx/src/frontend/components/interfaces/InterfacesPage.vue"; + +// Mock global objects +const mockAxios = { + get: vi.fn(), + post: vi.fn(), +}; +window.axios = mockAxios; + +const mockToast = { + success: vi.fn(), + error: vi.fn(), +}; +// We need to handle how ToastUtils is imported in the component +// If it's a global or imported, we might need a different approach. +// Let's assume it's available via window or we can mock the import if using vitest aliases. + +vi.mock("../../js/ToastUtils", () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock router/route +const mockRoute = { + query: {}, +}; +const mockRouter = { + push: vi.fn(), +}; + +describe("InterfacesPage.vue", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAxios.get.mockResolvedValue({ data: { interfaces: [], app_info: { is_reticulum_running: true } } }); + }); + + it("loads interfaces on mount", async () => { + mockAxios.get.mockImplementation((url) => { + if (url.includes("interfaces")) { + return Promise.resolve({ data: { interfaces: [{ name: "Test Iface", type: "TCP" }] } }); + } + if (url.includes("app/info")) { + return Promise.resolve({ data: { app_info: { is_reticulum_running: true } } }); + } + return Promise.reject(); + }); + + const wrapper = mount(InterfacesPage, { + global: { + mocks: { + $route: mockRoute, + $router: mockRouter, + $t: (msg) => msg, + }, + stubs: ["MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"], + }, + }); + + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); // wait for multiple awaits + + expect(mockAxios.get).toHaveBeenCalledWith("/api/v1/reticulum/interfaces"); + expect(wrapper.vm.interfaces.length).toBe(1); + }); + + it("tracks changes when an interface is enabled", async () => { + const wrapper = mount(InterfacesPage, { + global: { + mocks: { + $route: mockRoute, + $router: mockRouter, + $t: (msg) => msg, + }, + stubs: ["MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"], + }, + }); + + await wrapper.vm.enableInterface("test-iface"); + expect(wrapper.vm.hasPendingInterfaceChanges).toBe(true); + expect(wrapper.vm.modifiedInterfaceNames.has("test-iface")).toBe(true); + }); + + it("clears pending changes after RNS reload", async () => { + mockAxios.post.mockResolvedValue({ data: { message: "Reloaded" } }); + + const wrapper = mount(InterfacesPage, { + global: { + mocks: { + $route: mockRoute, + $router: mockRouter, + $t: (msg) => msg, + }, + stubs: ["MaterialDesignIcon", "IconButton", "Interface", "ImportInterfacesModal"], + }, + }); + + wrapper.vm.hasPendingInterfaceChanges = true; + wrapper.vm.modifiedInterfaceNames.add("test-iface"); + + await wrapper.vm.reloadRns(); + + expect(wrapper.vm.hasPendingInterfaceChanges).toBe(false); + expect(wrapper.vm.modifiedInterfaceNames.size).toBe(0); + expect(mockAxios.post).toHaveBeenCalledWith("/api/v1/reticulum/reload"); + }); +}); diff --git a/tests/frontend/MaterialDesignIcon.test.js b/tests/frontend/MaterialDesignIcon.test.js new file mode 100644 index 0000000..9a465e1 --- /dev/null +++ b/tests/frontend/MaterialDesignIcon.test.js @@ -0,0 +1,27 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import MaterialDesignIcon from "../../meshchatx/src/frontend/components/MaterialDesignIcon.vue"; + +describe("MaterialDesignIcon.vue", () => { + it("converts icon-name to mdiIconName", () => { + const wrapper = mount(MaterialDesignIcon, { + props: { iconName: "account-circle" }, + }); + expect(wrapper.vm.mdiIconName).toBe("mdiAccountCircle"); + }); + + it("renders svg with correct aria-label", () => { + const wrapper = mount(MaterialDesignIcon, { + props: { iconName: "home" }, + }); + expect(wrapper.find("svg").attributes("aria-label")).toBe("home"); + }); + + it("falls back to question mark for unknown icons", () => { + const wrapper = mount(MaterialDesignIcon, { + props: { iconName: "non-existent-icon" }, + }); + // mdiProgressQuestion should be used + expect(wrapper.vm.iconPath).not.toBe(""); + }); +}); diff --git a/tests/frontend/Toggle.test.js b/tests/frontend/Toggle.test.js new file mode 100644 index 0000000..8608e6d --- /dev/null +++ b/tests/frontend/Toggle.test.js @@ -0,0 +1,28 @@ +import { mount } from "@vue/test-utils"; +import { describe, it, expect } from "vitest"; +import Toggle from "../../meshchatx/src/frontend/components/forms/Toggle.vue"; + +describe("Toggle.vue", () => { + it("renders label when provided", () => { + const wrapper = mount(Toggle, { + props: { id: "test-toggle", label: "Test Label" }, + }); + expect(wrapper.text()).toContain("Test Label"); + }); + + it("emits update:modelValue on change", async () => { + const wrapper = mount(Toggle, { + props: { id: "test-toggle", modelValue: false }, + }); + const input = wrapper.find("input"); + await input.setChecked(true); + expect(wrapper.emitted("update:modelValue")[0]).toEqual([true]); + }); + + it("reflects modelValue prop", () => { + const wrapper = mount(Toggle, { + props: { id: "test-toggle", modelValue: true }, + }); + expect(wrapper.find("input").element.checked).toBe(true); + }); +});