CheddahBot/tests/test_ntfy.py

411 lines
14 KiB
Python

"""Tests for the ntfy.sh push notification sender."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import httpx
from cheddahbot.ntfy import NtfyChannel, NtfyNotifier
# ---------------------------------------------------------------------------
# NtfyChannel routing
# ---------------------------------------------------------------------------
class TestNtfyChannel:
def test_accepts_matching_category_and_pattern(self):
ch = NtfyChannel(
name="human_action",
server="https://ntfy.sh",
topic="test-topic",
categories=["clickup", "autocora"],
include_patterns=["completed", "SUCCESS"],
)
assert ch.accepts("ClickUp task completed: **Acme PR**", "clickup") is True
assert ch.accepts("AutoCora SUCCESS: **keyword**", "autocora") is True
def test_rejects_wrong_category(self):
ch = NtfyChannel(
name="human_action",
server="https://ntfy.sh",
topic="test-topic",
categories=["clickup"],
include_patterns=["completed"],
)
assert ch.accepts("Some autocora message completed", "autocora") is False
def test_rejects_non_matching_pattern(self):
ch = NtfyChannel(
name="human_action",
server="https://ntfy.sh",
topic="test-topic",
categories=["clickup"],
include_patterns=["completed"],
)
assert ch.accepts("Executing ClickUp task: **Acme PR**", "clickup") is False
def test_no_include_patterns_accepts_all_in_category(self):
ch = NtfyChannel(
name="all_clickup",
server="https://ntfy.sh",
topic="test-topic",
categories=["clickup"],
)
assert ch.accepts("Any message at all", "clickup") is True
def test_exclude_patterns_take_priority(self):
ch = NtfyChannel(
name="test",
server="https://ntfy.sh",
topic="test-topic",
categories=["clickup"],
include_patterns=["task"],
exclude_patterns=["Executing"],
)
assert ch.accepts("Executing ClickUp task", "clickup") is False
assert ch.accepts("ClickUp task completed", "clickup") is True
def test_case_insensitive_patterns(self):
ch = NtfyChannel(
name="test",
server="https://ntfy.sh",
topic="test-topic",
categories=["autocora"],
include_patterns=["success"],
)
assert ch.accepts("AutoCora SUCCESS: **kw**", "autocora") is True
def test_empty_topic_filtered_by_notifier(self):
ch = NtfyChannel(
name="empty", server="https://ntfy.sh", topic="",
categories=["clickup"],
)
notifier = NtfyNotifier([ch])
assert notifier.enabled is False
# ---------------------------------------------------------------------------
# NtfyNotifier
# ---------------------------------------------------------------------------
class TestNtfyNotifier:
@patch("cheddahbot.ntfy.httpx.post")
def test_notify_posts_to_matching_channel(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="human_action",
server="https://ntfy.sh",
topic="my-topic",
categories=["clickup"],
include_patterns=["completed"],
)
notifier = NtfyNotifier([ch])
notifier.notify("ClickUp task completed: **Acme PR**", "clickup")
# Wait for daemon thread
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args[0][0] == "https://ntfy.sh/my-topic"
assert call_args[1]["headers"]["Title"] == "CheddahBot [clickup]"
assert call_args[1]["headers"]["Priority"] == "high"
@patch("cheddahbot.ntfy.httpx.post")
def test_notify_skips_non_matching_channel(self, mock_post):
ch = NtfyChannel(
name="errors",
server="https://ntfy.sh",
topic="err-topic",
categories=["clickup"],
include_patterns=["failed"],
)
notifier = NtfyNotifier([ch])
notifier.notify("ClickUp task completed: **Acme PR**", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
mock_post.assert_not_called()
@patch("cheddahbot.ntfy.httpx.post")
def test_notify_routes_to_multiple_channels(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch1 = NtfyChannel(
name="all", server="https://ntfy.sh", topic="all-topic",
categories=["clickup"],
)
ch2 = NtfyChannel(
name="errors", server="https://ntfy.sh", topic="err-topic",
categories=["clickup"], include_patterns=["failed"],
)
notifier = NtfyNotifier([ch1, ch2])
notifier.notify("ClickUp task failed: **Acme**", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
assert mock_post.call_count == 2
@patch("cheddahbot.ntfy.httpx.post")
def test_webhook_error_is_swallowed(self, mock_post):
mock_post.side_effect = httpx.ConnectError("connection refused")
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"],
)
notifier = NtfyNotifier([ch])
# Should not raise
notifier.notify("ClickUp task completed: **test**", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
@patch("cheddahbot.ntfy.httpx.post")
def test_4xx_is_logged_not_raised(self, mock_post):
mock_post.return_value = MagicMock(status_code=400, text="Bad Request")
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"],
)
notifier = NtfyNotifier([ch])
notifier.notify("ClickUp task completed: **test**", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
def test_enabled_property(self):
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"],
)
assert NtfyNotifier([ch]).enabled is True
assert NtfyNotifier([]).enabled is False
# ---------------------------------------------------------------------------
# Post format
# ---------------------------------------------------------------------------
class TestPostFormat:
@patch("cheddahbot.ntfy.httpx.post")
def test_includes_tags_header(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"], tags="white_check_mark",
)
notifier = NtfyNotifier([ch])
notifier.notify("task completed", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
headers = mock_post.call_args[1]["headers"]
assert headers["Tags"] == "white_check_mark"
@patch("cheddahbot.ntfy.httpx.post")
def test_omits_tags_header_when_empty(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"], tags="",
)
notifier = NtfyNotifier([ch])
notifier.notify("task completed", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
headers = mock_post.call_args[1]["headers"]
assert "Tags" not in headers
@patch("cheddahbot.ntfy.httpx.post")
def test_custom_server_url(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="test", server="https://my-ntfy.example.com",
topic="topic", categories=["clickup"],
)
notifier = NtfyNotifier([ch])
notifier.notify("task completed", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
assert mock_post.call_args[0][0] == "https://my-ntfy.example.com/topic"
@patch("cheddahbot.ntfy.httpx.post")
def test_message_sent_as_body(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"],
)
notifier = NtfyNotifier([ch])
notifier.notify("Hello **world**", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
assert mock_post.call_args[1]["content"] == b"Hello **world**"
@patch("cheddahbot.ntfy.httpx.post")
def test_priority_header(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
ch = NtfyChannel(
name="test", server="https://ntfy.sh", topic="topic",
categories=["clickup"], priority="urgent",
)
notifier = NtfyNotifier([ch])
notifier.notify("task completed", "clickup")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
assert mock_post.call_args[1]["headers"]["Priority"] == "urgent"
# ---------------------------------------------------------------------------
# Dedup window
# ---------------------------------------------------------------------------
def _make_channel(**overrides) -> NtfyChannel:
defaults = dict(
name="errors",
server="https://ntfy.sh",
topic="test-topic",
categories=["alert"],
)
defaults.update(overrides)
return NtfyChannel(**defaults)
class TestDedup:
def test_first_message_goes_through(self):
notifier = NtfyNotifier([_make_channel()])
assert notifier._check_and_track("errors", "task X skipped") is True
def test_duplicate_permanently_suppressed(self):
notifier = NtfyNotifier([_make_channel()])
assert notifier._check_and_track("errors", "task X skipped") is True
assert notifier._check_and_track("errors", "task X skipped") is False
def test_duplicate_still_suppressed_after_day_rollover(self):
notifier = NtfyNotifier([_make_channel()])
assert notifier._check_and_track("errors", "task X skipped") is True
# Dedup memory persists even across date rollover
with patch.object(notifier, "_today", return_value="2099-01-01"):
assert notifier._check_and_track("errors", "task X skipped") is False
def test_different_messages_not_deduped(self):
notifier = NtfyNotifier([_make_channel()])
assert notifier._check_and_track("errors", "task A skipped") is True
assert notifier._check_and_track("errors", "task B skipped") is True
def test_same_message_different_channel_not_deduped(self):
notifier = NtfyNotifier([_make_channel()])
assert notifier._check_and_track("errors", "task X skipped") is True
assert notifier._check_and_track("alerts", "task X skipped") is True
# ---------------------------------------------------------------------------
# Daily cap
# ---------------------------------------------------------------------------
class TestDailyCap:
def test_sends_up_to_cap(self):
notifier = NtfyNotifier([_make_channel()], daily_cap=3)
for i in range(3):
assert notifier._check_and_track("errors", f"msg {i}") is True
assert notifier._check_and_track("errors", "msg 3") is False
def test_cap_resets_on_new_day(self):
notifier = NtfyNotifier([_make_channel()], daily_cap=2)
assert notifier._check_and_track("errors", "msg 0") is True
assert notifier._check_and_track("errors", "msg 1") is True
assert notifier._check_and_track("errors", "msg 2") is False
with patch.object(notifier, "_today", return_value="2099-01-01"):
assert notifier._check_and_track("errors", "msg 2") is True
# ---------------------------------------------------------------------------
# 429 backoff
# ---------------------------------------------------------------------------
class TestRateLimitBackoff:
def test_429_suppresses_rest_of_day(self):
notifier = NtfyNotifier([_make_channel()])
notifier._mark_rate_limited()
assert notifier._check_and_track("errors", "new message") is False
def test_429_resets_next_day(self):
notifier = NtfyNotifier([_make_channel()])
notifier._mark_rate_limited()
assert notifier._check_and_track("errors", "blocked") is False
with patch.object(notifier, "_today", return_value="2099-01-01"):
assert notifier._check_and_track("errors", "unblocked") is True
def test_post_sets_rate_limit_on_429(self):
channel = _make_channel()
notifier = NtfyNotifier([channel])
mock_resp = MagicMock(status_code=429, text="Rate limited")
with patch("cheddahbot.ntfy.httpx.post", return_value=mock_resp):
notifier._post(channel, "test msg", "alert")
assert notifier._rate_limited_until == notifier._today()
# ---------------------------------------------------------------------------
# Notify integration with dedup
# ---------------------------------------------------------------------------
class TestNotifyDedup:
@patch("cheddahbot.ntfy.httpx.post")
def test_notify_skips_deduped_messages(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
channel = _make_channel()
notifier = NtfyNotifier([channel])
notifier.notify("same msg", "alert")
notifier.notify("same msg", "alert")
import threading
for t in threading.enumerate():
if t.daemon and t.is_alive():
t.join(timeout=2)
# Only one post — second was deduped
mock_post.assert_called_once()