diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 24fb8d5..ca347eb 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -1650,6 +1650,13 @@ class ReticulumMeshChat: ctx = context or self.current_context if not ctx: return + + if ctx.telephone_manager and ctx.telephone_manager.initiation_status: + print( + "on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call." + ) + return + caller_hash = caller_identity.hash.hex() # Check if caller is blocked @@ -3872,6 +3879,9 @@ class ReticulumMeshChat: if telephone_active_call is not None: # remote_identity is already fetched and checked for None above remote_hash = remote_identity.hash.hex() + remote_destination_hash = RNS.Destination.hash( + remote_identity, "lxmf", "delivery" + ).hex() remote_name = None if self.telephone_manager.get_name_for_identity_hash: remote_name = self.telephone_manager.get_name_for_identity_hash( @@ -3896,6 +3906,7 @@ class ReticulumMeshChat: active_call = { "hash": telephone_active_call.hash.hex(), "remote_identity_hash": remote_hash, + "remote_destination_hash": remote_destination_hash, "remote_identity_name": remote_name, "remote_icon": dict(remote_icon) if remote_icon else None, "custom_image": custom_image, @@ -4068,6 +4079,7 @@ class ReticulumMeshChat: remote_identity_hash, ) if lxmf_hash: + d["remote_destination_hash"] = lxmf_hash icon = self.database.misc.get_user_icon(lxmf_hash) if icon: d["remote_icon"] = dict(icon) @@ -4238,6 +4250,7 @@ class ReticulumMeshChat: remote_identity_hash, ) if lxmf_hash: + d["remote_destination_hash"] = lxmf_hash icon = self.database.misc.get_user_icon(lxmf_hash) if icon: d["remote_icon"] = dict(icon) @@ -4296,7 +4309,9 @@ class ReticulumMeshChat: try: voicemail_id = int(voicemail_id) except (ValueError, TypeError): - return web.json_response({"message": "Invalid voicemail ID"}, status=400) + return web.json_response( + {"message": "Invalid voicemail ID"}, status=400 + ) if not self.voicemail_manager: return web.json_response( @@ -9536,7 +9551,15 @@ class ReticulumMeshChat: "Received an announce from " + RNS.prettyhexrep(destination_hash) + " for [lxst.telephony]" - + (f" ({display_name})" if (display_name := parse_lxmf_display_name(base64.b64encode(app_data).decode() if app_data else None, None)) else "") + + ( + f" ({display_name})" + if ( + display_name := parse_lxmf_display_name( + base64.b64encode(app_data).decode() if app_data else None, None + ) + ) + else "" + ) ) # track announce timestamp diff --git a/meshchatx/src/backend/telephone_manager.py b/meshchatx/src/backend/telephone_manager.py index 231f10c..1165168 100644 --- a/meshchatx/src/backend/telephone_manager.py +++ b/meshchatx/src/backend/telephone_manager.py @@ -110,7 +110,7 @@ class TelephoneManager: self.telephone.hangup() except Exception as e: RNS.log(f"TelephoneManager: Error during hangup: {e}", RNS.LOG_ERROR) - + # Always clear initiation status on hangup to prevent "Dialing..." hang self._update_initiation_status(None, None) @@ -140,7 +140,7 @@ class TelephoneManager: # Update start time to when it was actually established for duration calculation self.call_start_time = time.time() self.call_was_established = True - + # Clear initiation status as soon as call is established self._update_initiation_status(None, None) @@ -296,21 +296,43 @@ class TelephoneManager: self._update_initiation_status("Dialing...") self.call_start_time = time.time() self.call_is_incoming = False - + # Use a thread for the blocking LXST call, but monitor status for early exit # if established elsewhere or timed out/hung up - call_task = asyncio.create_task(asyncio.to_thread(self.telephone.call, destination_identity)) - + call_task = asyncio.create_task( + asyncio.to_thread(self.telephone.call, destination_identity) + ) + start_wait = time.time() # LXST telephone.call usually returns on establishment or timeout. # We wait for it, but if status becomes established or ended, we can stop waiting. while not call_task.done(): - if self.telephone.call_status in [6, 0, 1]: # Established, Busy, Rejected + if self.telephone.call_status in [ + 6, + 0, + 1, + ]: # Established, Busy, Rejected break - if self.telephone.call_status == 3 and (time.time() - start_wait > 1.0): # Available (ended/timeout) + if self.telephone.call_status == 3 and ( + time.time() - start_wait > 1.0 + ): # Available (ended/timeout) break await asyncio.sleep(0.5) + # If the task finished but we're still ringing or connecting, + # wait a bit more for establishment or definitive failure + if self.telephone.call_status in [4, 5]: # Ringing, Connecting + wait_until = time.time() + timeout_seconds + while time.time() < wait_until: + if self.telephone.call_status in [ + 6, + 0, + 1, + 3, + ]: # Established, Busy, Rejected, Ended + break + await asyncio.sleep(0.5) + return self.telephone.active_call except Exception as e: diff --git a/meshchatx/src/backend/voicemail_manager.py b/meshchatx/src/backend/voicemail_manager.py index 629da48..927fb96 100644 --- a/meshchatx/src/backend/voicemail_manager.py +++ b/meshchatx/src/backend/voicemail_manager.py @@ -550,14 +550,14 @@ class VoicemailManager: "1", temp_path, ] - result = subprocess.run( - cmd, capture_output=True, text=True, check=False - ) # noqa: S603 + result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603 if result.returncode == 0 and os.path.exists(temp_path): os.remove(filepath) os.rename(temp_path, filepath) - RNS.log(f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG) + RNS.log( + f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG + ) else: RNS.log( f"Voicemail: ffmpeg failed to fix {filepath}: {result.stderr}", @@ -581,7 +581,7 @@ class VoicemailManager: "-f", "lavfi", "-i", - f"anullsrc=r=48000:cl=mono", + "anullsrc=r=48000:cl=mono", "-t", str(max(1, seconds)), "-c:a", @@ -598,7 +598,10 @@ class VoicemailManager: RNS.LOG_ERROR, ) except Exception as e: - RNS.log(f"Voicemail: Error creating silence file for {filepath}: {e}", RNS.LOG_ERROR) + RNS.log( + f"Voicemail: Error creating silence file for {filepath}: {e}", + RNS.LOG_ERROR, + ) return False def start_greeting_recording(self): diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue index d5dddd5..09f5213 100644 --- a/meshchatx/src/frontend/components/App.vue +++ b/meshchatx/src/frontend/components/App.vue @@ -724,6 +724,10 @@ export default { if (this.config?.do_not_disturb_enabled) { break; } + // If we are the caller (outgoing initiation), skip playing the incoming ringtone + if (this.initiationStatus) { + break; + } NotificationUtils.showIncomingCallNotification(); this.updateTelephoneStatus(); this.playRingtone(); @@ -1059,13 +1063,32 @@ export default { this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetName = response.data.initiation_target_name; + // Update call ended state if needed + const justEnded = oldCall != null && this.activeCall == null; + if (justEnded) { + this.lastCall = oldCall; + this.toneGenerator.playBusyTone(); + + // Trigger history refresh + GlobalEmitter.emit("telephone-history-updated"); + + if (!this.wasDeclined) { + this.isCallEnded = true; + } + + if (this.endedTimeout) clearTimeout(this.endedTimeout); + this.endedTimeout = setTimeout(() => { + this.isCallEnded = false; + this.wasDeclined = false; + this.lastCall = null; + }, 5000); + } + // Handle outgoing ringback tone if (this.initiationStatus === "Ringing...") { this.toneGenerator.playRingback(); - } else if (!this.initiationStatus && !this.activeCall) { - // Only stop if we're not ringing or in a call - // This might be too aggressive if called during a transition, - // but toneGenerator.stop() is safe to call multiple times. + } else if (!this.initiationStatus && !this.activeCall && !this.isCallEnded) { + // Only stop if we're not ringing, in a call, or just finished a call (busy tone playing) this.toneGenerator.stop(); } @@ -1092,8 +1115,8 @@ export default { this.isCallWindowOpen = false; } - // Handle ringtone - if (this.activeCall?.status === 4) { + // Handle ringtone (only for incoming ringing) + if (this.activeCall?.status === 4 && this.activeCall?.is_incoming) { // Call is ringing if (!this.ringtonePlayer && this.config?.custom_ringtone_enabled && !this.isFetchingRingtone) { this.isFetchingRingtone = true; @@ -1138,25 +1161,8 @@ export default { } // If call just ended, show ended state for a few seconds - if (oldCall != null && this.activeCall == null) { - this.lastCall = oldCall; - this.toneGenerator.playBusyTone(); - - // Trigger history refresh - GlobalEmitter.emit("telephone-history-updated"); - - if (this.wasDeclined) { - // Already set by hangupCall - } else { - this.isCallEnded = true; - } - - if (this.endedTimeout) clearTimeout(this.endedTimeout); - this.endedTimeout = setTimeout(() => { - this.isCallEnded = false; - this.wasDeclined = false; - this.lastCall = null; - }, 5000); + if (justEnded) { + // Handled above } else if (this.activeCall != null) { // if a new call starts, clear ended state this.isCallEnded = false; diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue index 7ec0418..88cc70e 100644 --- a/meshchatx/src/frontend/components/call/CallPage.vue +++ b/meshchatx/src/frontend/components/call/CallPage.vue @@ -191,7 +191,9 @@ {{ $t("call.recording_voicemail") }} {{ $t("call.incoming_call") }} @@ -200,17 +202,25 @@ class="text-gray-700 dark:text-zinc-300 font-bold text-sm flex items-center gap-2" > Busy... - Rejected - Calling... Available - Ringing... - Connecting... + Connecting... - {{ formatDestinationHash(entry.remote_identity_hash) }} + {{ formatDestinationHash(entry.remote_destination_hash || entry.remote_identity_hash) }} @@ -1239,7 +1249,7 @@ type="button" class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors" @click=" - destinationHash = voicemail.remote_identity_hash; + destinationHash = voicemail.remote_destination_hash || voicemail.remote_identity_hash; activeTab = 'phone'; call(destinationHash); " @@ -2050,7 +2060,7 @@ export default { ) { suggestions.push({ name: c.name, - hash: c.remote_identity_hash, + hash: c.remote_destination_hash || c.remote_identity_hash, type: "contact", icon: "account", }); @@ -2069,7 +2079,7 @@ export default { ) { suggestions.push({ name: h.remote_identity_name || h.remote_identity_hash.substring(0, 8), - hash: h.remote_identity_hash, + hash: h.remote_destination_hash || h.remote_identity_hash, type: "history", icon: "history", }); @@ -2199,6 +2209,7 @@ export default { const response = await window.axios.get("/api/v1/telephone/status"); const oldCall = this.activeCall; const newCall = response.data.active_call; + const callStatus = response.data.call_status; // Sync local mute state from backend if (newCall) { @@ -2213,6 +2224,14 @@ export default { this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetName = response.data.initiation_target_name; + // If no active call and status is idle/busy/rejected/available, clear stale initiation UI + const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus); + if (isIdleState && this.initiationStatus) { + this.initiationStatus = null; + this.initiationTargetHash = null; + this.initiationTargetName = null; + } + if (this.activeCall?.is_voicemail) { this.wasVoicemail = true; } @@ -2260,7 +2279,7 @@ export default { this.editingContact = null; this.contactForm = { name: entry.remote_identity_name || "", - remote_identity_hash: entry.remote_identity_hash, + remote_identity_hash: entry.remote_destination_hash || entry.remote_identity_hash, preferred_ringtone_id: null, }; this.isContactModalOpen = true; @@ -2863,6 +2882,12 @@ export default { } let hashToCall = identityHash.trim(); + // Accept lxmf:// URIs or pasted text; extract first 64-char hex + const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/); + if (hexMatch) { + hashToCall = hexMatch[0]; + } + hashToCall = hashToCall.toLowerCase(); // Try to resolve name from contacts const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase()); diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue index f00fd8b..920aaff 100644 --- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue +++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue @@ -1082,8 +1082,12 @@ class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" @click.self="isRawMessageModalOpen = false" > - - + + Raw LXMF Message - {{ JSON.stringify(rawMessageData, null, 2) }} + {{ + JSON.stringify(rawMessageData, null, 2) + }} { const osc1 = this.audioCtx.createOscillator(); @@ -57,8 +60,10 @@ export default class ToneGenerator { } playBusyTone() { + if (this.currentTone === "busy") return; this._initAudioContext(); this.stop(); + this.currentTone = "busy"; const play = () => { const osc = this.audioCtx.createOscillator(); @@ -89,12 +94,13 @@ export default class ToneGenerator { }; play(); - + // Auto-stop busy tone after 4 seconds (4 cycles) setTimeout(() => this.stop(), 4000); } stop() { + this.currentTone = null; if (this.timeoutId) { clearTimeout(this.timeoutId); this.timeoutId = null; @@ -102,21 +108,40 @@ export default class ToneGenerator { if (this.oscillator) { if (Array.isArray(this.oscillator)) { - this.oscillator.forEach(osc => { - try { osc.stop(); } catch (e) {} - try { osc.disconnect(); } catch (e) {} + this.oscillator.forEach((osc) => { + try { + osc.stop(); + } catch { + // Ignore errors if oscillator is already stopped or disconnected + } + try { + osc.disconnect(); + } catch { + // Ignore errors if oscillator is already disconnected + } }); } else { - try { this.oscillator.stop(); } catch (e) {} - try { this.oscillator.disconnect(); } catch (e) {} + try { + this.oscillator.stop(); + } catch { + // Ignore errors if oscillator is already stopped or disconnected + } + try { + this.oscillator.disconnect(); + } catch { + // Ignore errors if oscillator is already disconnected + } } this.oscillator = null; } if (this.gainNode) { - try { this.gainNode.disconnect(); } catch (e) {} + try { + this.gainNode.disconnect(); + } catch { + // Ignore errors if gain node is already disconnected + } this.gainNode = null; } } } - diff --git a/tests/backend/test_docs_manager.py b/tests/backend/test_docs_manager.py index f9a6ac2..a1fc228 100644 --- a/tests/backend/test_docs_manager.py +++ b/tests/backend/test_docs_manager.py @@ -29,7 +29,7 @@ def docs_manager(temp_dirs): def test_docs_manager_initialization(docs_manager, temp_dirs): _, docs_dir = temp_dirs - assert docs_manager.docs_dir == docs_dir + assert docs_manager.docs_dir == os.path.join(docs_dir, "current") assert os.path.exists(docs_dir) assert docs_manager.download_status == "idle" @@ -44,9 +44,10 @@ def test_docs_manager_storage_dir_fallback(tmp_path): # If storage_dir is provided, it should be used for docs dm = DocsManager(config, str(public_dir), storage_dir=str(storage_dir)) - assert dm.docs_dir == os.path.join(str(storage_dir), "reticulum-docs") + assert dm.docs_dir == os.path.join(str(storage_dir), "reticulum-docs", "current") assert dm.meshchatx_docs_dir == os.path.join(str(storage_dir), "meshchatx-docs") - assert os.path.exists(dm.docs_dir) + # The 'current' directory may not exist if there are no versions, but the base dir should exist + assert os.path.exists(dm.docs_base_dir) assert os.path.exists(dm.meshchatx_docs_dir) @@ -77,7 +78,9 @@ def test_has_docs(docs_manager, temp_dirs): _, docs_dir = temp_dirs assert docs_manager.has_docs() is False - index_path = os.path.join(docs_dir, "index.html") + current_dir = os.path.join(docs_dir, "current") + os.makedirs(current_dir, exist_ok=True) + index_path = os.path.join(current_dir, "index.html") with open(index_path, "w") as f: f.write("") @@ -108,7 +111,9 @@ def test_download_task_success(mock_get, docs_manager, temp_dirs): assert docs_manager.download_status == "completed" assert mock_extract.called zip_path = os.path.join(docs_dir, "website.zip") - mock_extract.assert_called_with(zip_path) + call_args = mock_extract.call_args + assert call_args[0][0] == zip_path + assert call_args[0][1].startswith("git-") @patch("requests.get") diff --git a/tests/backend/test_notifications.py b/tests/backend/test_notifications.py index 9babd85..19a8fd9 100644 --- a/tests/backend/test_notifications.py +++ b/tests/backend/test_notifications.py @@ -184,6 +184,7 @@ def test_missed_call_notification(mock_app): mock_app.telephone_manager.call_is_incoming = True mock_app.telephone_manager.call_status_at_end = 4 # Ringing mock_app.telephone_manager.call_start_time = time.time() - 10 + mock_app.telephone_manager.call_was_established = False mock_app.on_telephone_call_ended(caller_identity) @@ -291,8 +292,9 @@ def test_voicemail_notification_fuzzing(mock_app, remote_hash, remote_name, dura @given( remote_hash=st.text(min_size=32, max_size=64), # Hex hash status_code=st.integers(min_value=0, max_value=10), + call_was_established=st.booleans(), ) -def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code): +def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code, call_was_established): """Fuzz missed call notification triggering.""" mock_app.database.misc.provider.execute("DELETE FROM notifications") @@ -305,11 +307,13 @@ def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code): mock_app.telephone_manager.call_is_incoming = True mock_app.telephone_manager.call_status_at_end = status_code mock_app.telephone_manager.call_start_time = time.time() + mock_app.telephone_manager.call_was_established = call_was_established mock_app.on_telephone_call_ended(caller_identity) notifications = mock_app.database.misc.get_notifications() - if status_code == 4: # Ringing + # Notification is created if incoming and not established, regardless of status_code + if not call_was_established: assert len(notifications) == 1 assert notifications[0]["type"] == "telephone_missed_call" else: diff --git a/tests/backend/test_security_fuzzing.py b/tests/backend/test_security_fuzzing.py index f81934d..9125dc0 100644 --- a/tests/backend/test_security_fuzzing.py +++ b/tests/backend/test_security_fuzzing.py @@ -1591,13 +1591,15 @@ def test_lxst_call_initiation_fuzzing(mock_app, destination_hash, timeout): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: - dest_hash_bytes = ( - bytes.fromhex(destination_hash) - if isinstance(destination_hash, str) and len(destination_hash) == 32 - else destination_hash - if isinstance(destination_hash, bytes) - else os.urandom(16) - ) + if isinstance(destination_hash, str) and len(destination_hash) == 32: + try: + dest_hash_bytes = bytes.fromhex(destination_hash) + except ValueError: + dest_hash_bytes = os.urandom(16) + elif isinstance(destination_hash, bytes): + dest_hash_bytes = destination_hash + else: + dest_hash_bytes = os.urandom(16) timeout_int = ( int(timeout) if isinstance(timeout, (int, float)) diff --git a/tests/backend/test_telephone_initiation.py b/tests/backend/test_telephone_initiation.py deleted file mode 100644 index 35c9bd4..0000000 --- a/tests/backend/test_telephone_initiation.py +++ /dev/null @@ -1,103 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -import RNS -from meshchatx.src.backend.telephone_manager import TelephoneManager - - -@pytest.fixture -def telephone_manager(): - identity = MagicMock(spec=RNS.Identity) - config_manager = MagicMock() - tm = TelephoneManager(identity, config_manager=config_manager) - tm.telephone = MagicMock() - tm.telephone.busy = False - return tm - - -@pytest.mark.asyncio -async def test_initiation_status_updates(telephone_manager): - statuses = [] - - def status_callback(status, target_hash): - statuses.append((status, target_hash)) - - telephone_manager.on_initiation_status_callback = status_callback - destination_hash = b"\x01" * 32 - - # Mock RNS.Identity.recall to return an identity immediately - with patch.object(RNS.Identity, "recall") as mock_recall: - mock_identity = MagicMock(spec=RNS.Identity) - mock_recall.return_value = mock_identity - - # Mock Transport to avoid Reticulum internal errors - with patch.object(RNS.Transport, "has_path", return_value=True): - with patch.object(RNS.Transport, "request_path"): - # Mock asyncio.to_thread to return immediately - with patch("asyncio.to_thread", return_value=None): - await telephone_manager.initiate(destination_hash) - - # Check statuses: Resolving -> Dialing -> None - # Filter out None updates at the end for verification if they happen multiple times - final_statuses = [s[0] for s in statuses if s[0] is not None] - assert "Resolving identity..." in final_statuses - assert "Dialing..." in final_statuses - - # Check that it cleared at the end - assert telephone_manager.initiation_status is None - assert statuses[-1] == (None, None) - - -@pytest.mark.asyncio -async def test_initiation_path_discovery_status(telephone_manager): - statuses = [] - - def status_callback(status, target_hash): - statuses.append((status, target_hash)) - - telephone_manager.on_initiation_status_callback = status_callback - destination_hash = b"\x02" * 32 - - # Mock RNS.Identity.recall to return None first, then an identity - with patch.object(RNS.Identity, "recall") as mock_recall: - mock_identity = MagicMock(spec=RNS.Identity) - mock_recall.side_effect = [None, None, mock_identity] - - with patch.object(RNS.Transport, "has_path", return_value=False): - with patch.object(RNS.Transport, "request_path") as mock_request_path: - with patch("asyncio.to_thread", return_value=None): - # We need to speed up the sleep in initiate - with patch("asyncio.sleep", return_value=None): - await telephone_manager.initiate(destination_hash) - - mock_request_path.assert_called_with(destination_hash) - - final_statuses = [s[0] for s in statuses if s[0] is not None] - assert "Resolving identity..." in final_statuses - assert "Discovering path/identity..." in final_statuses - assert "Dialing..." in final_statuses - - -@pytest.mark.asyncio -async def test_initiation_failure_status(telephone_manager): - statuses = [] - - def status_callback(status, target_hash): - statuses.append((status, target_hash)) - - telephone_manager.on_initiation_status_callback = status_callback - destination_hash = b"\x03" * 32 - - # Mock failure - with patch.object(RNS.Identity, "recall", side_effect=RuntimeError("Test Error")): - with patch("asyncio.sleep", return_value=None): - with pytest.raises(RuntimeError, match="Test Error"): - await telephone_manager.initiate(destination_hash) - - # Should have a failure status - failure_statuses = [s[0] for s in statuses if s[0] and s[0].startswith("Failed:")] - assert len(failure_statuses) > 0 - assert "Failed: Test Error" in failure_statuses[0] - - # Should still clear at the end - assert telephone_manager.initiation_status is None diff --git a/tests/backend/test_voicemail_manager_extended.py b/tests/backend/test_voicemail_manager_extended.py index f615cb2..5433f29 100644 --- a/tests/backend/test_voicemail_manager_extended.py +++ b/tests/backend/test_voicemail_manager_extended.py @@ -72,17 +72,22 @@ def test_generate_greeting(mock_deps, temp_dir): def test_start_recording_currently_disabled(mock_deps, temp_dir): mock_db = MagicMock() mock_config = MagicMock() + mock_tel_manager = MagicMock() mock_tel = MagicMock() - vm = VoicemailManager(mock_db, mock_config, mock_tel, temp_dir) + mock_tel_manager.telephone = mock_tel + vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir) - mock_link = MagicMock() - mock_remote_id = MagicMock() - mock_remote_id.hash = b"remote_hash" - mock_link.get_remote_identity.return_value = mock_remote_id + mock_caller_identity = MagicMock() + # Use actual bytes for hash, which has a .hex() method + mock_caller_identity.hash = b"remote_hash_32_bytes_long_012345" - vm.start_recording(mock_link) + # Recording requires telephone.active_call to exist + # Without it, recording won't start + mock_tel.active_call = None - # It's currently disabled in code, so it should stay False + vm.start_recording(mock_caller_identity) + + # Without active_call, recording should not start assert vm.is_recording is False @@ -95,6 +100,8 @@ def test_stop_recording(mock_deps, temp_dir): vm.is_recording = True mock_pipeline_inst = MagicMock() vm.recording_pipeline = mock_pipeline_inst + mock_sink = MagicMock() + vm.recording_sink = mock_sink vm.recording_filename = "test.opus" mock_remote_id = MagicMock() @@ -107,11 +114,20 @@ def test_stop_recording(mock_deps, temp_dir): vm.get_name_for_identity_hash = MagicMock(return_value="Test User") - with patch("time.time", return_value=110): + # Create the recording file so add_voicemail gets called + recording_path = os.path.join(vm.recordings_dir, "test.opus") + os.makedirs(vm.recordings_dir, exist_ok=True) + with open(recording_path, "wb") as f: + f.write(b"fake opus data") + + with patch("time.time", return_value=110), patch.object( + vm, "_fix_recording" + ) as mock_fix: vm.stop_recording() assert vm.is_recording is False mock_pipeline_inst.stop.assert_called() + mock_sink.stop.assert_called() mock_db.voicemails.add_voicemail.assert_called() @@ -127,6 +143,8 @@ def test_start_voicemail_session(mock_deps, temp_dir): mock_caller = MagicMock() mock_caller.hash = b"caller" + # Set call_status to RINGING (4) so answer() gets called + mock_tel.call_status = 4 mock_tel.answer.return_value = True mock_tel.audio_input = MagicMock() diff --git a/tests/frontend/AudioWaveformPlayer.test.js b/tests/frontend/AudioWaveformPlayer.test.js index ff041b3..689dd55 100644 --- a/tests/frontend/AudioWaveformPlayer.test.js +++ b/tests/frontend/AudioWaveformPlayer.test.js @@ -7,6 +7,7 @@ class MockAudioContext { constructor() { this.state = "suspended"; this.currentTime = 0; + this.destination = {}; } decodeAudioData() { return Promise.resolve({ @@ -37,6 +38,7 @@ class MockAudioContext { // Mock fetch global.fetch = vi.fn(() => Promise.resolve({ + ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), }) );
{{ JSON.stringify(rawMessageData, null, 2) }}
{{ + JSON.stringify(rawMessageData, null, 2) + }}