Files
lnbits/tests/unit/test_services_notifications.py
T
2026-03-30 13:12:46 +03:00

599 lines
19 KiB
Python

import asyncio
from datetime import datetime, timezone
from http import HTTPStatus
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import uuid4
import httpx
import pytest
from pywebpush import WebPushException
from pytest_mock.plugin import MockerFixture
from lnbits.core.crud import (
create_account,
create_payment,
create_wallet,
create_webpush_subscription,
get_payment,
get_webpush_subscription,
update_payment,
update_wallet,
)
from lnbits.core.models import Account, CreatePayment, Payment, PaymentState, Wallet
from lnbits.core.models.notifications import NotificationType
from lnbits.core.models.users import UserExtra, UserNotifications
from lnbits.core.models.wallets import (
WalletPermission,
WalletSharePermission,
WalletShareStatus,
)
from lnbits.core.services.notifications import (
dispatch_webhook,
enqueue_admin_notification,
enqueue_user_notification,
process_next_notification,
send_admin_notification,
send_chat_payment_notification,
send_email,
send_email_notification,
send_notification,
send_nostr_notification,
send_nostr_notifications,
send_payment_notification,
send_payment_push_notification,
send_push_notification,
send_telegram_message,
send_telegram_notification,
send_user_notification,
send_ws_payment_notification,
)
from lnbits.settings import Settings
class MockHTTPClient:
def __init__(self, post_response=None, post_exception=None):
self.post_response = post_response
self.post_exception = post_exception
self.posts: list[tuple[str, dict]] = []
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return None
async def post(self, url, **kwargs):
self.posts.append((url, kwargs))
if self.post_exception:
raise self.post_exception
return self.post_response
async def _create_wallet(
notifications: UserNotifications | None = None,
*,
name: str | None = None,
) -> Wallet:
account = Account(
id=uuid4().hex,
username=f"user_{uuid4().hex[:8]}",
extra=UserExtra(notifications=notifications or UserNotifications()),
)
await create_account(account)
return await create_wallet(
user_id=account.id,
wallet_name=name or f"wallet_{account.id[:8]}",
)
async def _create_payment(
wallet: Wallet,
*,
amount_msat: int = 2_000,
status: PaymentState = PaymentState.SUCCESS,
webhook: str | None = None,
webhook_status: str | None = None,
memo: str | None = "memo",
extra: dict | None = None,
) -> Payment:
checking_id = f"checking_{uuid4().hex[:8]}"
payment = await create_payment(
checking_id=checking_id,
data=CreatePayment(
wallet_id=wallet.id,
payment_hash=uuid4().hex,
bolt11=f"bolt11-{checking_id}",
amount_msat=amount_msat,
memo=memo or "",
webhook=webhook,
extra=extra or {},
),
status=status,
)
if webhook_status is not None:
payment.webhook_status = webhook_status
await update_payment(payment)
return await get_payment(checking_id)
@pytest.mark.anyio
async def test_enqueue_and_process_notifications(settings: Settings, mocker: MockerFixture):
queue: asyncio.Queue = asyncio.Queue()
admin_mock = mocker.patch(
"lnbits.core.services.notifications.send_admin_notification",
mocker.AsyncMock(),
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.send_user_notification",
mocker.AsyncMock(),
)
mocker.patch("lnbits.core.services.notifications.notifications_queue", queue)
mocker.patch(
"lnbits.core.services.notifications._is_message_type_enabled",
return_value=True,
)
enqueue_admin_notification(NotificationType.settings_update, {"username": "alice"})
await process_next_notification()
assert admin_mock.await_count == 1
assert admin_mock.await_args.args[0].startswith(f"[{settings.lnbits_site_title}]")
assert "alice" in admin_mock.await_args.args[0]
assert admin_mock.await_args.args[1] == NotificationType.settings_update.value
user_notifications = UserNotifications(email_address="alice@example.com")
enqueue_user_notification(
NotificationType.text_message,
{"message": "hello"},
user_notifications,
)
await process_next_notification()
assert user_mock.await_count == 1
assert user_mock.await_args.args[0] == user_notifications
assert "hello" in user_mock.await_args.args[1]
assert user_mock.await_args.args[2] == NotificationType.text_message.value
@pytest.mark.anyio
async def test_send_admin_and_user_notification_use_expected_targets(
settings: Settings, mocker: MockerFixture
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_notification",
mocker.AsyncMock(),
)
original_chat_id = settings.lnbits_telegram_notifications_chat_id
original_identifiers = list(settings.lnbits_nostr_notifications_identifiers)
original_emails = list(settings.lnbits_email_notifications_to_emails)
try:
settings.lnbits_telegram_notifications_chat_id = "chat-id"
settings.lnbits_nostr_notifications_identifiers = ["alice@example.com"]
settings.lnbits_email_notifications_to_emails = ["admin@example.com"]
await send_admin_notification("hello", "settings_update")
await send_user_notification(
UserNotifications(
telegram_chat_id="user-chat",
nostr_identifier="bob@example.com",
email_address="bob@example.com",
),
"hello user",
"text_message",
)
finally:
settings.lnbits_telegram_notifications_chat_id = original_chat_id
settings.lnbits_nostr_notifications_identifiers = original_identifiers
settings.lnbits_email_notifications_to_emails = original_emails
assert send_mock.await_args_list[0].args == (
"chat-id",
["alice@example.com"],
["admin@example.com"],
"hello",
"settings_update",
)
assert send_mock.await_args_list[1].args == (
"user-chat",
["bob@example.com"],
["bob@example.com"],
"hello user",
"text_message",
)
@pytest.mark.anyio
async def test_send_notification_uses_available_channels_and_swallows_exceptions(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = True
mocker.patch.object(
type(settings),
"is_telegram_notifications_configured",
return_value=True,
)
mocker.patch.object(
type(settings),
"is_nostr_notifications_configured",
return_value=True,
)
telegram_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_notification",
mocker.AsyncMock(side_effect=Exception("telegram boom")),
)
nostr_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notifications",
mocker.AsyncMock(return_value=["alice@example.com"]),
)
email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email_notification",
mocker.AsyncMock(side_effect=Exception("email boom")),
)
await send_notification(
"chat-id",
["alice@example.com"],
["alice@example.com"],
"hello",
"text_message",
)
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
telegram_mock.assert_awaited_once()
nostr_mock.assert_awaited_once()
email_mock.assert_awaited_once()
@pytest.mark.anyio
async def test_send_nostr_notifications_and_single_notification(
mocker: MockerFixture,
):
send_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_notification",
mocker.AsyncMock(side_effect=[None, Exception("boom"), None]),
)
result = await send_nostr_notifications(["ok-1", "bad", "ok-2"], "hello")
assert result == ["ok-1", "ok-2"]
assert send_mock.await_count == 3
fetch_mock = mocker.patch(
"lnbits.core.services.notifications.fetch_nip5_details",
mocker.AsyncMock(return_value=("pubkey", ["wss://relay"])),
)
normalize_mock = mocker.patch(
"lnbits.core.services.notifications.normalize_private_key",
return_value="server-private-key",
)
dm_mock = mocker.patch(
"lnbits.core.services.notifications.send_nostr_dm",
mocker.AsyncMock(),
)
await send_nostr_notification("alice@example.com", "hello")
fetch_mock.assert_awaited_once_with("alice@example.com")
normalize_mock.assert_called_once()
dm_mock.assert_awaited_once_with(
"server-private-key",
"pubkey",
"hello",
["wss://relay"],
)
@pytest.mark.anyio
async def test_send_telegram_message_and_wrapper(
settings: Settings, mocker: MockerFixture
):
response = httpx.Response(
200,
request=httpx.Request("POST", "https://api.telegram.org"),
json={"ok": True},
)
client = MockHTTPClient(post_response=response)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=client,
)
result = await send_telegram_message("token", "chat-id", "hello")
assert result == {"ok": True}
assert client.posts[0][0].endswith("/bottoken/sendMessage")
original_token = settings.lnbits_telegram_notifications_access_token
try:
settings.lnbits_telegram_notifications_access_token = "wrapper-token"
wrapper_mock = mocker.patch(
"lnbits.core.services.notifications.send_telegram_message",
mocker.AsyncMock(return_value={"ok": True}),
)
await send_telegram_notification("chat-id", "hello")
finally:
settings.lnbits_telegram_notifications_access_token = original_token
wrapper_mock.assert_awaited_once_with("wrapper-token", "chat-id", "hello")
@pytest.mark.anyio
async def test_send_email_notification_and_send_email(
settings: Settings, mocker: MockerFixture
):
original_email_enabled = settings.lnbits_email_notifications_enabled
try:
settings.lnbits_email_notifications_enabled = False
disabled = await send_email_notification(["alice@example.com"], "hello")
assert disabled["status"] == "error"
settings.lnbits_email_notifications_enabled = True
send_email_mock = mocker.patch(
"lnbits.core.services.notifications.send_email",
mocker.AsyncMock(return_value=True),
)
enabled = await send_email_notification(["alice@example.com"], "hello")
assert enabled == {"status": "ok"}
send_email_mock.assert_awaited_once()
finally:
settings.lnbits_email_notifications_enabled = original_email_enabled
smtp_server = MagicMock()
smtp_context = MagicMock()
smtp_context.__enter__.return_value = smtp_server
smtp_context.__exit__.return_value = None
mocker.patch(
"lnbits.core.services.notifications.smtplib.SMTP",
return_value=smtp_context,
)
assert (
await send_email(
"smtp.example.com",
587,
"",
"password",
"from@example.com",
["to@example.com"],
"Subject",
"Body",
)
is True
)
smtp_server.starttls.assert_called_once()
smtp_server.login.assert_called_once_with("from@example.com", "password")
smtp_server.sendmail.assert_called_once()
with pytest.raises(ValueError, match="Invalid from email address"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"bad-email",
["to@example.com"],
"Subject",
"Body",
)
with pytest.raises(ValueError, match="No email addresses provided"):
await send_email(
"smtp.example.com",
587,
"user",
"password",
"from@example.com",
[],
"Subject",
"Body",
)
@pytest.mark.anyio
async def test_dispatch_webhook_marks_missing_invalid_and_failed_requests(
mocker: MockerFixture,
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, webhook=None)
await dispatch_webhook(payment)
assert (await get_payment(payment.checking_id)).webhook_status == "-1"
invalid_payment = await _create_payment(wallet, webhook="https://invalid.example")
invalid_client = MockHTTPClient(
post_response=httpx.Response(
200,
request=httpx.Request("POST", invalid_payment.webhook),
json={"ok": True},
)
)
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
side_effect=ValueError("blocked"),
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=invalid_client,
)
await dispatch_webhook(invalid_payment)
assert (await get_payment(invalid_payment.checking_id)).webhook_status in {"-1", "200"}
error_payment = await _create_payment(wallet, webhook="https://error.example")
mocker.patch(
"lnbits.core.services.notifications.check_callback_url",
return_value=None,
)
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_response=httpx.Response(
500,
request=httpx.Request("POST", error_payment.webhook),
)
),
)
await dispatch_webhook(error_payment)
assert (await get_payment(error_payment.checking_id)).webhook_status == "500"
request_payment = await _create_payment(wallet, webhook="https://request.example")
mocker.patch(
"lnbits.core.services.notifications.httpx.AsyncClient",
return_value=MockHTTPClient(
post_exception=httpx.RequestError(
"boom",
request=httpx.Request("POST", request_payment.webhook),
)
),
)
await dispatch_webhook(request_payment)
assert (await get_payment(request_payment.checking_id)).webhook_status == "-1"
@pytest.mark.anyio
async def test_send_payment_notification_fans_out_to_shared_wallet_and_webhook(
mocker: MockerFixture,
):
wallet = await _create_wallet(name="Primary Wallet")
shared_wallet = await _create_wallet(name="Shared Wallet")
wallet.extra.shared_with = [
WalletSharePermission(
request_id="share-1",
username="bob",
shared_with_wallet_id=shared_wallet.id,
permissions=[WalletPermission.VIEW_PAYMENTS],
status=WalletShareStatus.APPROVED,
)
]
await update_wallet(wallet)
payment = await _create_payment(wallet, webhook="https://webhook.example")
ws_mock = mocker.patch(
"lnbits.core.services.notifications.send_ws_payment_notification",
mocker.AsyncMock(),
)
chat_mock = mocker.patch(
"lnbits.core.services.notifications.send_chat_payment_notification",
mocker.AsyncMock(),
)
push_mock = mocker.patch(
"lnbits.core.services.notifications.send_payment_push_notification",
mocker.AsyncMock(),
)
dispatch_mock = mocker.patch(
"lnbits.core.services.notifications.dispatch_webhook",
mocker.AsyncMock(),
)
await send_payment_notification(wallet, payment)
assert [call.args[0].id for call in ws_mock.await_args_list] == [
wallet.id,
shared_wallet.id,
]
chat_mock.assert_awaited_once_with(wallet, payment)
push_mock.assert_awaited_once_with(wallet, payment)
dispatch_mock.assert_awaited_once_with(payment)
@pytest.mark.anyio
async def test_send_ws_payment_notification_and_chat_notifications(
settings: Settings, mocker: MockerFixture
):
user_notifications = UserNotifications(
telegram_chat_id="chat-id",
nostr_identifier="alice@example.com",
email_address="alice@example.com",
incoming_payments_sats=1,
outgoing_payments_sats=1,
)
wallet = await _create_wallet(user_notifications)
payment = await _create_payment(
wallet,
amount_msat=-2_000,
extra={"wallet_fiat_currency": "USD", "wallet_fiat_amount": 5.25},
)
websocket_mock = mocker.patch(
"lnbits.core.services.notifications.websocket_manager.send",
mocker.AsyncMock(),
)
await send_ws_payment_notification(wallet, payment)
assert [call.args[0] for call in websocket_mock.await_args_list] == [
wallet.inkey,
wallet.adminkey,
payment.payment_hash,
]
original_outgoing = settings.lnbits_notification_outgoing_payment_amount_sats
original_incoming = settings.lnbits_notification_incoming_payment_amount_sats
try:
settings.lnbits_notification_outgoing_payment_amount_sats = 1
settings.lnbits_notification_incoming_payment_amount_sats = 1
admin_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_admin_notification"
)
user_mock = mocker.patch(
"lnbits.core.services.notifications.enqueue_user_notification"
)
await send_chat_payment_notification(wallet, payment)
finally:
settings.lnbits_notification_outgoing_payment_amount_sats = original_outgoing
settings.lnbits_notification_incoming_payment_amount_sats = original_incoming
assert admin_mock.call_args.args[0] == NotificationType.outgoing_payment
assert "`5.25`*USD* / " in admin_mock.call_args.args[1]["fiat_value_fmt"]
assert user_mock.call_args.args[0] == NotificationType.outgoing_payment
@pytest.mark.anyio
async def test_send_payment_push_notification_and_cleanup_gone_subscriptions(
settings: Settings, mocker: MockerFixture
):
wallet = await _create_wallet()
payment = await _create_payment(wallet, amount_msat=2_000, memo="Thanks")
endpoint = f"https://push.example/{uuid4().hex}"
subscription = await create_webpush_subscription(
endpoint,
wallet.user,
'{"endpoint":"https://push.example"}',
"push.example",
)
send_push_mock = mocker.patch(
"lnbits.core.services.notifications.send_push_notification",
mocker.AsyncMock(),
)
await send_payment_push_notification(wallet, payment)
assert send_push_mock.await_args.args[0].endpoint == subscription.endpoint
assert send_push_mock.await_args.args[1] == f"LNbits: {wallet.name}"
assert "received 2 sats" in send_push_mock.await_args.args[2]
assert send_push_mock.await_args.args[3] == (
f"https://{subscription.host}/wallet?usr={wallet.user}&wal={wallet.id}"
)
original_privkey = settings.lnbits_webpush_privkey
try:
settings.lnbits_webpush_privkey = ""
exc = WebPushException("gone")
exc.response = SimpleNamespace(status_code=HTTPStatus.GONE, text="gone")
mocker.patch(
"lnbits.core.services.notifications.webpush",
side_effect=exc,
)
await send_push_notification(subscription, "Title", "Body")
finally:
settings.lnbits_webpush_privkey = original_privkey
assert await get_webpush_subscription(subscription.endpoint, wallet.user) is None