feat(tests): update fe/be tests and contract api routes

This commit is contained in:
Ivan committed 2026-04-27 11:16:52 -05:00
1 parent bbc067b7c1
commit d3ca433a7c
40 files changed
+2462 -74

No files matched your search

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