feat(tests): add comprehensive test suite for backend functionality, including database, configuration, and telemetry utilities
All checks were successful
CI / test-backend (push) Successful in 15s
CI / lint (push) Successful in 42s
CI / build-frontend (push) Successful in 9m35s

This commit is contained in:
2026-01-02 19:41:05 -06:00
parent d7a5926e6e
commit 00b4290735
15 changed files with 895 additions and 0 deletions

0
tests/__init__.py Normal file
View File

View File

View 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")

View 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

View 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()

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

View 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

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

View 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

View 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

View 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"]

View 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");
});
});

View 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");
});
});

View 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("");
});
});

View 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);
});
});