mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
334 lines
13 KiB
Python
334 lines
13 KiB
Python
# SPDX-License-Identifier: 0BSD
|
|
|
|
"""Regression guards for call contact matching and Codec2/profile selection.
|
|
|
|
These lock in bugs that broke real calls:
|
|
1. Contacts saved under LXMF destination hashes were rejected by contacts-only.
|
|
2. Configured audio profiles never reached LXST telephone.call().
|
|
3. Invalid legacy profile id 2 silently mapped to Opus instead of a real profile.
|
|
4. Codec2 profiles crashed or were unusable when pycodec2/libcodec2 was missing.
|
|
5. Incoming calls during outbound dial left the remote ringing forever.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from meshchatx.meshchat import ReticulumMeshChat
|
|
from meshchatx.src.backend.config_manager import ConfigManager
|
|
from meshchatx.src.backend.database import Database
|
|
from meshchatx.src.backend.database.contacts import ContactsDAO
|
|
from meshchatx.src.backend.database.provider import DatabaseProvider
|
|
from meshchatx.src.backend.database.schema import DatabaseSchema
|
|
from meshchatx.src.backend.telephone_manager import TelephoneManager
|
|
|
|
pytest.importorskip("LXST")
|
|
from LXST.Primitives.Telephony import Profiles
|
|
|
|
IDENTITY = "a1" * 16
|
|
LXMF = "b2" * 16
|
|
LXST = "c3" * 16
|
|
CALLER_HASH_HEX = IDENTITY
|
|
|
|
|
|
@pytest.fixture
|
|
def contacts_dao():
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
provider = DatabaseProvider(path)
|
|
DatabaseSchema(provider).initialize()
|
|
yield ContactsDAO(provider)
|
|
provider.close()
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
@pytest.fixture
|
|
def config_db():
|
|
fd, path = tempfile.mkstemp(suffix=".db")
|
|
os.close(fd)
|
|
database = Database(path)
|
|
database.initialize()
|
|
yield database
|
|
database.close()
|
|
if os.path.exists(path):
|
|
os.remove(path)
|
|
|
|
|
|
def _caller_identity():
|
|
ident = MagicMock()
|
|
ident.hash = bytes.fromhex(CALLER_HASH_HEX)
|
|
return ident
|
|
|
|
|
|
def _policy_app():
|
|
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
|
|
ctx = MagicMock()
|
|
tm = MagicMock()
|
|
tm.initiation_status = None
|
|
tm.telephone = MagicMock()
|
|
ctx.telephone_manager = tm
|
|
ctx.config = MagicMock()
|
|
ctx.database = MagicMock()
|
|
ctx.database.announces.get_announce_by_hash.return_value = None
|
|
ctx.database.announces.get_announces_by_identity_hash.return_value = []
|
|
ctx.voicemail_manager = MagicMock()
|
|
app.current_context = ctx
|
|
app.is_destination_blocked = MagicMock(return_value=False)
|
|
app.websocket_broadcast = MagicMock()
|
|
app.get_name_for_identity_hash = MagicMock(return_value="Caller")
|
|
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF)
|
|
app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST)
|
|
return app
|
|
|
|
|
|
class TestContactsOnlyIdentityVsLxmfRegression:
|
|
"""Chat UI often stores LXMF dest as remote_identity_hash; calls use identity hash."""
|
|
|
|
def test_dao_matches_identity_via_related_lxmf_hash(self, contacts_dao):
|
|
# Broken historical save: LXMF destination used as primary key
|
|
contacts_dao.add_contact("Friend", LXMF, lxmf_address=LXMF)
|
|
assert contacts_dao.get_contact_by_identity_hash(IDENTITY) is None
|
|
matched = contacts_dao.get_contact_by_identity_hash(
|
|
IDENTITY,
|
|
related_hashes=[LXMF],
|
|
)
|
|
assert matched is not None
|
|
assert matched["name"] == "Friend"
|
|
|
|
def test_is_contact_true_for_identity_when_saved_as_lxmf(self):
|
|
contact = {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
|
|
|
|
def lookup(primary, related_hashes=None):
|
|
keys = {primary, *(related_hashes or ())}
|
|
if LXMF in keys:
|
|
return contact
|
|
return None
|
|
|
|
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
|
|
ctx = MagicMock()
|
|
ctx.database = MagicMock()
|
|
ctx.database.announces.get_announce_by_hash.return_value = None
|
|
ctx.database.announces.get_announces_by_identity_hash.return_value = [
|
|
{
|
|
"destination_hash": LXMF,
|
|
"identity_hash": IDENTITY,
|
|
"aspect": "lxmf.delivery",
|
|
},
|
|
]
|
|
ctx.database.contacts.get_contact_by_identity_hash.side_effect = lookup
|
|
app.current_context = ctx
|
|
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF)
|
|
app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST)
|
|
|
|
assert app._is_contact(IDENTITY) is True
|
|
|
|
def test_incoming_contacts_only_allows_lxmf_saved_contact(self):
|
|
app = _policy_app()
|
|
app.config.do_not_disturb_enabled.get.return_value = False
|
|
app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
|
|
app.config.block_all_from_strangers.get.return_value = False
|
|
app.config.telephone_enabled.get.return_value = True
|
|
app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
|
|
{
|
|
"destination_hash": LXMF,
|
|
"identity_hash": IDENTITY,
|
|
"aspect": "lxmf.delivery",
|
|
},
|
|
]
|
|
|
|
def lookup(primary, related_hashes=None):
|
|
keys = {primary, *(related_hashes or ())}
|
|
if LXMF in keys or IDENTITY in keys:
|
|
return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
|
|
return None
|
|
|
|
app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup
|
|
caller = _caller_identity()
|
|
|
|
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
|
|
async_utils.run_async = MagicMock()
|
|
ReticulumMeshChat.on_incoming_telephone_call(app, caller)
|
|
|
|
app.current_context.voicemail_manager.handle_incoming_call.assert_called_once()
|
|
app.current_context.telephone_manager.telephone.hangup.assert_not_called()
|
|
async_utils.run_async.assert_called_once()
|
|
|
|
|
|
class TestIncomingWhileDialingRegression:
|
|
def test_rejects_with_hangup_instead_of_silent_ignore(self):
|
|
app = _policy_app()
|
|
app.current_context.telephone_manager.initiation_status = "Calling..."
|
|
caller = _caller_identity()
|
|
|
|
with patch("meshchatx.meshchat.threading.Timer") as mock_timer:
|
|
|
|
def run_timer(delay, fn):
|
|
fn()
|
|
t = MagicMock()
|
|
t.start = MagicMock()
|
|
return t
|
|
|
|
mock_timer.side_effect = run_timer
|
|
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
|
|
async_utils.run_async = MagicMock()
|
|
ReticulumMeshChat.on_incoming_telephone_call(app, caller)
|
|
|
|
app.current_context.telephone_manager.telephone.hangup.assert_called_once()
|
|
app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
|
|
|
|
|
|
class TestAudioProfilePassthroughRegression:
|
|
"""LXST switch_profile is a no-op when idle; profile must go to call()."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_outbound_call_receives_codec2_profile(self):
|
|
tm = TelephoneManager(identity=MagicMock())
|
|
tm.telephone = MagicMock()
|
|
tm.telephone.busy = False
|
|
tm.telephone.call_status = 3
|
|
tm.telephone.active_call = None
|
|
tm._path_poll_interval_s = 0.005
|
|
tm._path_retry_interval_s = 0.01
|
|
tm._status_poll_interval_s = 0.01
|
|
tm.preferred_profile_id = Profiles.BANDWIDTH_LOW
|
|
seen = {}
|
|
|
|
def capture_call(identity, profile=None, mode=None):
|
|
seen["profile"] = profile
|
|
seen["mode"] = mode
|
|
tm.telephone.call_status = 0
|
|
|
|
tm.telephone.call.side_effect = capture_call
|
|
destination_hash = bytes.fromhex("aa" * 16)
|
|
|
|
with (
|
|
patch(
|
|
"meshchatx.src.backend.telephone_manager.RNS.Identity.recall",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"meshchatx.src.backend.telephone_manager.RNS.Transport.has_path",
|
|
return_value=True,
|
|
),
|
|
patch.object(TelephoneManager, "codec2_available", return_value=True),
|
|
patch(
|
|
"meshchatx.src.backend.telephone_manager.RNS.Destination",
|
|
) as dest_cls,
|
|
):
|
|
dest_cls.return_value.hash = destination_hash
|
|
await tm.initiate(destination_hash, timeout_seconds=1)
|
|
|
|
assert seen["profile"] == Profiles.BANDWIDTH_LOW
|
|
assert seen["mode"] == Profiles.MODE_FULL_DUPLEX
|
|
|
|
def test_init_does_not_rely_on_idle_switch_profile(self, tmp_path):
|
|
cfg = MagicMock()
|
|
cfg.telephone_enabled.get.return_value = True
|
|
cfg.telephone_audio_profile_id.get.return_value = Profiles.BANDWIDTH_VERY_LOW
|
|
|
|
with (
|
|
patch(
|
|
"meshchatx.src.backend.telephone_manager.Telephone",
|
|
) as telephone_cls,
|
|
patch.object(TelephoneManager, "codec2_available", return_value=True),
|
|
):
|
|
telephone = telephone_cls.return_value
|
|
tm = TelephoneManager(
|
|
identity=MagicMock(),
|
|
config_manager=cfg,
|
|
storage_dir=str(tmp_path),
|
|
)
|
|
tm.init_telephone()
|
|
assert tm.preferred_profile_id == Profiles.BANDWIDTH_VERY_LOW
|
|
telephone.switch_profile.assert_not_called()
|
|
|
|
def test_invalid_legacy_profile_two_falls_back(self):
|
|
tm = TelephoneManager(identity=MagicMock())
|
|
assert tm.resolve_audio_profile_id(2) == Profiles.DEFAULT_PROFILE
|
|
|
|
def test_codec2_unavailable_falls_back_to_opus_default(self):
|
|
tm = TelephoneManager(identity=MagicMock())
|
|
with patch.object(TelephoneManager, "codec2_available", return_value=False):
|
|
assert (
|
|
tm.resolve_audio_profile_id(Profiles.BANDWIDTH_ULTRA_LOW)
|
|
== Profiles.DEFAULT_PROFILE
|
|
)
|
|
|
|
|
|
class TestLegacyProfileMigrationRegression:
|
|
def test_persisted_profile_two_migrates_to_default(self, config_db):
|
|
config = ConfigManager(config_db)
|
|
config.telephone_audio_profile_id.set(2)
|
|
reloaded = ConfigManager(config_db)
|
|
assert reloaded.telephone_audio_profile_id.get() == 64
|
|
|
|
def test_fresh_default_is_lxst_default_profile(self, config_db):
|
|
config = ConfigManager(config_db)
|
|
assert config.telephone_audio_profile_id.get() == Profiles.DEFAULT_PROFILE
|
|
|
|
|
|
class TestAndroidCodec2PackagingRegression:
|
|
def test_vendor_wheels_include_libcodec2_for_all_abis(self):
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
vendor = Path(__file__).resolve().parents[2] / "android" / "vendor"
|
|
if not vendor.is_dir():
|
|
pytest.skip("android/vendor not present (gitignored)")
|
|
for abi in ("arm64_v8a", "armeabi_v7a", "x86_64"):
|
|
wheels = sorted(vendor.glob(f"pycodec2-*-android_24_{abi}.whl"))
|
|
if not wheels:
|
|
pytest.skip(f"missing pycodec2 for {abi}")
|
|
with zipfile.ZipFile(wheels[-1]) as zin:
|
|
assert "pycodec2/libcodec2.so" in zin.namelist()
|
|
lib_wheels = sorted(
|
|
vendor.glob(f"chaquopy_libcodec2-*-android_24_{abi}.whl"),
|
|
)
|
|
if not lib_wheels:
|
|
pytest.skip(f"missing chaquopy_libcodec2 for {abi}")
|
|
with zipfile.ZipFile(lib_wheels[-1]) as zin:
|
|
assert "chaquopy/lib/libcodec2.so" in zin.namelist()
|
|
|
|
def test_android_lxst_get_codec_guards_missing_codec2(self):
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
whl = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "android"
|
|
/ "vendor"
|
|
/ "lxst-0.4.8-py3-none-any.whl"
|
|
)
|
|
if not whl.is_file():
|
|
pytest.skip("android/vendor/lxst wheel not present (gitignored)")
|
|
with zipfile.ZipFile(whl) as zin:
|
|
telephony = zin.read("LXST/Primitives/Telephony.py").decode()
|
|
codecs_init = zin.read("LXST/Codecs/__init__.py").decode()
|
|
assert "if Codec2 is not None:" in telephony
|
|
assert "_CODEC2_IMPORT_ERROR" in codecs_init
|
|
|
|
def test_pycodec2_roundtrip_for_call_codec2_profiles(self):
|
|
import numpy as np
|
|
from LXST.Codecs import Codec2
|
|
|
|
assert TelephoneManager.codec2_available() is True
|
|
for pid in (
|
|
Profiles.BANDWIDTH_ULTRA_LOW,
|
|
Profiles.BANDWIDTH_VERY_LOW,
|
|
Profiles.BANDWIDTH_LOW,
|
|
):
|
|
codec = Profiles.get_codec(pid)
|
|
assert isinstance(codec, Codec2)
|
|
spf = codec.c2.samples_per_frame()
|
|
pcm = (0.05 * np.sin(np.linspace(0, 4 * np.pi, spf))).astype(np.float32)
|
|
pcm_i16 = (pcm * 32767).astype(np.int16)
|
|
encoded = codec.c2.encode(pcm_i16)
|
|
decoded = codec.c2.decode(encoded)
|
|
assert len(encoded) == codec.c2.bytes_per_frame()
|
|
assert len(decoded) == spf
|