mcdbus/tests/test_notify_confirm.py
Ryan Malloy c8a1ce0fdb Add desktop notification confirmation fallback, update README for PyPI
When MCP elicitation is unavailable (most clients), fall back to
org.freedesktop.Notifications with Approve/Deny action buttons.
Opt-in via MCDBUS_NOTIFY_CONFIRM=1. Silence (timeout) is denial.

Fixes signal race where NotificationClosed stomped ActionInvoked
result in the same event loop iteration.
2026-03-06 14:47:39 -07:00

144 lines
5.4 KiB
Python

"""Tests for D-Bus notification confirmation fallback."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from dbus_fast import MessageType
from mcdbus._notify_confirm import notify_confirm
NID = 42
IFACE = "org.freedesktop.Notifications"
def _make_signal(interface, member, body):
"""Create a mock D-Bus signal message."""
msg = MagicMock()
msg.message_type = MessageType.SIGNAL
msg.interface = interface
msg.member = member
msg.body = body
return msg
@pytest.fixture
def mock_bus():
"""Mock MessageBus with handler registration tracking."""
bus = MagicMock()
bus._handlers = []
def _add(h):
bus._handlers.append(h)
def _remove(h):
if h in bus._handlers:
bus._handlers.remove(h)
bus.add_message_handler = MagicMock(side_effect=_add)
bus.remove_message_handler = MagicMock(side_effect=_remove)
return bus
def _fire_signal(mock_bus, interface, member, body, delay=0.01):
"""Schedule a signal delivery after a brief delay."""
async def _deliver():
await asyncio.sleep(delay)
msg = _make_signal(interface, member, body)
for handler in list(mock_bus._handlers):
handler(msg)
asyncio.create_task(_deliver())
class TestNotifyConfirm:
async def test_approve_returns_true(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is True
async def test_deny_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is False
async def test_dismissed_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
_fire_signal(mock_bus, IFACE, "NotificationClosed", [NID, 2])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?")
assert result is False
async def test_timeout_returns_false(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=0.05)
assert result is False
async def test_service_unavailable_raises(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
raise RuntimeError("org.freedesktop.DBus.Error.ServiceUnknown")
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
with pytest.raises(RuntimeError, match="ServiceUnknown"):
await notify_confirm(mock_bus, "Test", "Confirm?")
async def test_handler_ignores_wrong_nid(self, mock_bus):
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
if member == "Notify":
# Wrong nid first, correct nid after
_fire_signal(mock_bus, IFACE, "ActionInvoked", [999, "approve"], delay=0.01)
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "deny"], delay=0.03)
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
result = await notify_confirm(mock_bus, "Test", "Confirm?", timeout=1.0)
# Wrong nid was ignored; correct nid with "deny" triggered
assert result is False
async def test_cleanup_called(self, mock_bus):
members_called = []
async def mock_call(bus, destination, path, interface, member,
signature="", body=None, timeout=30.0):
members_called.append(member)
if member == "Notify":
_fire_signal(mock_bus, IFACE, "ActionInvoked", [NID, "approve"])
return [NID]
return None
with patch("mcdbus._notify_confirm.call_bus_method", side_effect=mock_call):
await notify_confirm(mock_bus, "Test", "Confirm?")
mock_bus.remove_message_handler.assert_called_once()
assert "CloseNotification" in members_called
assert members_called.count("RemoveMatch") == 2