mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-10-23 16:02:28 +00:00
feat(tests): update fe/be tests and contract api routes
This commit is contained in:
40 files changed
+2462
-74
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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")
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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},
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user