feat(tests): add comprehensive test suite for backend functionality, including database, configuration, and telemetry utilities
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/backend/__init__.py
Normal file
0
tests/backend/__init__.py
Normal file
20
tests/backend/test_colour_utils.py
Normal file
20
tests/backend/test_colour_utils.py
Normal file
@@ -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")
|
||||
64
tests/backend/test_config_manager.py
Normal file
64
tests/backend/test_config_manager.py
Normal file
@@ -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
|
||||
109
tests/backend/test_database.py
Normal file
109
tests/backend/test_database.py
Normal file
@@ -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()
|
||||
58
tests/backend/test_interface_config_parser.py
Normal file
58
tests/backend/test_interface_config_parser.py
Normal file
@@ -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"
|
||||
25
tests/backend/test_interface_editor.py
Normal file
25
tests/backend/test_interface_editor.py
Normal file
@@ -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
|
||||
33
tests/backend/test_lxmf_message_fields.py
Normal file
33
tests/backend/test_lxmf_message_fields.py
Normal file
@@ -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"
|
||||
97
tests/backend/test_meshchat_utils.py
Normal file
97
tests/backend/test_meshchat_utils.py
Normal file
@@ -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
|
||||
261
tests/backend/test_rns_lifecycle.py
Normal file
261
tests/backend/test_rns_lifecycle.py
Normal file
@@ -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
|
||||
43
tests/backend/test_telemetry_utils.py
Normal file
43
tests/backend/test_telemetry_utils.py
Normal file
@@ -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"]
|
||||
20
tests/frontend/IconButton.test.js
Normal file
20
tests/frontend/IconButton.test.js
Normal file
@@ -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: '<span class="test-icon">icon</span>',
|
||||
},
|
||||
});
|
||||
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");
|
||||
});
|
||||
});
|
||||
110
tests/frontend/InterfacesPage.test.js
Normal file
110
tests/frontend/InterfacesPage.test.js
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
27
tests/frontend/MaterialDesignIcon.test.js
Normal file
27
tests/frontend/MaterialDesignIcon.test.js
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
28
tests/frontend/Toggle.test.js
Normal file
28
tests/frontend/Toggle.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user