Files
OwncastSentry/tests/test_notification_service.py
T
LogalDeveloper c0bb2cf423
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 29s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 53s
Used Maubot and Mautrix helpers for bot messages.
2026-05-25 21:22:28 -04:00

588 lines
21 KiB
Python

# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the notification service."""
import asyncio
import logging
import time
from typing import TYPE_CHECKING
import pytest
from mautrix.types import MessageType
from owncastsentry.metrics import MetricsService
from owncastsentry.notification_service import (
_SECONDS_BETWEEN_NOTIFICATIONS,
NotificationService,
_sanitize_for_plain_text,
)
from tests.conftest import _StubMatrixClient, generate_metrics_output
if TYPE_CHECKING:
from owncastsentry.repository import StreamRepository, SubscriptionRepository
def _make_service(
*,
client: _StubMatrixClient,
subscription_repo: SubscriptionRepository,
metrics: MetricsService | None = None,
) -> NotificationService:
"""Build a NotificationService with a stub client and real repo."""
return NotificationService(
client=client,
subscription_repo=subscription_repo,
logger=logging.getLogger("test"),
metrics=metrics or MetricsService(),
)
class TestSanitizeForPlainText:
"""Plain text sanitization for notifications."""
@pytest.mark.parametrize(
("input_text", "expected"),
[
pytest.param("hello world", "hello world", id="plain-text"),
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
pytest.param(
"too many spaces", "too many spaces", id="spaces-collapsed"
),
pytest.param("", "", id="empty-string"),
],
)
def test_sanitizes(self, input_text: str, expected: str) -> None:
"""Sanitize the text for safe plain-text rendering."""
assert _sanitize_for_plain_text(input_text) == expected
class TestCanNotify:
"""Rate-limiting logic for notification cooldowns."""
def test_first_notification_allowed(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Allow the first notification for an unseen domain."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
assert service._can_notify("example.com") is True
def test_within_cooldown_blocked(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Block notifications within the cooldown window."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.notification_timers_cache["example.com"] = time.monotonic()
assert service._can_notify("example.com") is False
def test_after_cooldown_allowed(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Allow notifications after the cooldown window expires."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
# Subtract an extra second to ensure the cooldown has fully elapsed
service.notification_timers_cache["example.com"] = (
time.monotonic() - _SECONDS_BETWEEN_NOTIFICATIONS - 1
)
assert service._can_notify("example.com") is True
class TestGetLastNotificationTime:
"""Last notification timestamp retrieval."""
def test_returns_cached_value(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return the cached timestamp for a known domain."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.notification_timers_cache["example.com"] = 12345.0
assert service.get_last_notification_time("example.com") == 12345.0
def test_returns_zero_for_unknown(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Return 0 for a domain that has never been notified."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
assert service.get_last_notification_time("unknown.com") == 0
class TestClearNotificationState:
"""Notification cache cleanup for deleted domains."""
def test_clears_cached_notification_time(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Remove cached notification state for a domain."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.notification_timers_cache["example.com"] = 12345.0
service.clear_notification_state("example.com")
assert service.get_last_notification_time("example.com") == 0
def test_missing_domain_is_ignored(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Ignore cleanup for a domain with no cached state."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
service.clear_notification_state("unknown.com")
assert service.get_last_notification_time("unknown.com") == 0
class TestFormatMessage:
"""Notification message formatting."""
@pytest.mark.parametrize(
("name", "title", "domain", "tags", "title_change", "expected"),
[
pytest.param(
"My Stream",
"Playing Games",
"example.com",
[],
False,
"🎥 My Stream is now live!\n"
"Stream Title: Playing Games\n"
"\n"
"To tune in, visit: https://example.com/",
id="go-live-with-title",
),
pytest.param(
"My Stream",
"New Title",
"example.com",
[],
True,
"📝 My Stream has changed its stream title!\n"
"Stream Title: New Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="title-change",
),
pytest.param(
"My Stream",
"",
"example.com",
[],
False,
"🎥 My Stream is now live!\n\nTo tune in, visit: https://example.com/",
id="go-live-no-title",
),
pytest.param(
"",
"Title",
"example.com",
[],
False,
"🎥 example.com is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="name-fallback-to-domain",
),
pytest.param(
"Stream",
"Title",
"example.com",
["gaming", "fun"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#gaming #fun",
id="with-tags",
),
pytest.param(
"Stream",
"Title",
"example.com",
[".hidden", "visible"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#visible",
id="dot-prefix-tag-filtered",
),
pytest.param(
"Stream",
"Title",
"example.com",
[".secret"],
False,
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/",
id="all-tags-dot-prefixed",
),
],
)
def test_format_message(
self,
name: str,
title: str,
domain: str,
tags: list[str],
*,
title_change: bool,
expected: str,
subscription_repo: SubscriptionRepository,
) -> None:
"""Format the notification message with expected content."""
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
result = service._format_message(
name, title, domain, tags, title_change=title_change
)
assert result == expected
class TestNotifyStreamLive:
"""Live notification sending behavior."""
async def test_sends_to_all_subscribed_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send notifications to every room subscribed to the domain."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await subscription_repo.add("example.com", "!room2:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", ["tag"])
assert len(client.sent_messages) == 2
room_ids = {msg.room_id for msg in client.sent_messages}
assert room_ids == {"!room1:matrix.org", "!room2:matrix.org"}
expected_body = (
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/\n"
"\n"
"#tag"
)
for msg in client.sent_messages:
assert msg.content.msgtype == MessageType.NOTICE
assert msg.content.body == expected_body
async def test_records_cooldown_after_success(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a cooldown after at least one room receives a notification."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
before_send = time.monotonic()
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert service.get_last_notification_time("example.com") >= before_send
async def test_no_cooldown_when_no_subscribed_rooms(
self, subscription_repo: SubscriptionRepository
) -> None:
"""Do not record a cooldown if no room receives the notification."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 0
assert service.get_last_notification_time("example.com") == 0
async def test_no_cooldown_when_all_deliveries_fail(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Do not record a cooldown if every room delivery fails."""
client = _StubMatrixClient()
client.should_fail_for_rooms.add("!bad:matrix.org")
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!bad:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 0
assert service.get_last_notification_time("example.com") == 0
async def test_counts_cancelled_delivery_as_failure(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Count a cancelled delivery result as a failure."""
client = _StubMatrixClient()
async def send_notice(_room_id: str, _text: str) -> None:
raise asyncio.CancelledError
client.send_notice = send_notice
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert service.get_last_notification_time("example.com") == 0
async def test_skips_when_rate_limited(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip sending when the domain is within the rate-limit cooldown."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
service.notification_timers_cache["example.com"] = time.monotonic()
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 0
async def test_counts_failures(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Deliver to other subscribed rooms when one room delivery fails."""
client = _StubMatrixClient()
client.should_fail_for_rooms.add("!bad:matrix.org")
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!bad:matrix.org")
await subscription_repo.add("example.com", "!good:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
assert len(client.sent_messages) == 1
assert client.sent_messages[0].room_id == "!good:matrix.org"
assert client.sent_messages[0].content.msgtype == MessageType.NOTICE
assert client.sent_messages[0].content.body == (
"🎥 Stream is now live!\n"
"Stream Title: Title\n"
"\n"
"To tune in, visit: https://example.com/"
)
class TestSendCleanupWarning:
"""Cleanup warning notification sending."""
async def test_sends_warning_to_all_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send cleanup warning to all subscribed rooms."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.send_cleanup_warning("example.com")
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.msgtype == MessageType.NOTICE
assert client.sent_messages[0].content.body == (
"⚠️ Warning: Subscription Cleanup Scheduled\n"
"\n"
"The Owncast instance at example.com has been "
"unreachable for 83 days. If it remains "
"unreachable for 7 more days "
"(90 days total), this subscription "
"will be automatically removed."
)
class TestSendCleanupDeletion:
"""Cleanup deletion notification sending."""
async def test_sends_deletion_to_all_rooms(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send cleanup deletion notice to all subscribed rooms."""
client = _StubMatrixClient()
service = _make_service(client=client, subscription_repo=subscription_repo)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room1:matrix.org")
await service.send_cleanup_deletion("example.com")
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.msgtype == MessageType.NOTICE
assert client.sent_messages[0].content.body == (
"🗑️ Subscription Automatically Removed\n"
"\n"
"The Owncast instance at example.com has been "
"unreachable for 90 days and has been "
"automatically removed from subscriptions in this "
"room.\n"
"\n"
"If the instance comes online again and you want to "
"resubscribe, run `!subscribe example.com`."
)
class TestNotificationMetrics:
"""Notification metrics recording."""
async def test_records_live_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a live notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="live"} 1.0'
)
assert expected in output
async def test_records_title_change_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a title_change notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live(
"example.com", "Stream", "Title", [], title_change=True
)
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="title_change"} 1.0'
)
assert expected in output
async def test_records_cleanup_warning_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a cleanup_warning notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.send_cleanup_warning("example.com")
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="cleanup_warning"} 1.0'
)
assert expected in output
async def test_records_cleanup_deletion_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record a cleanup_deletion notification metric."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.send_cleanup_deletion("example.com")
output = generate_metrics_output(metrics)
expected = (
"owncastsentry_notification_delivery_total"
'{result="success",type="cleanup_deletion"} 1.0'
)
assert expected in output
async def test_no_metric_when_rate_limited(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Do not record metric when notification is rate-limited."""
client = _StubMatrixClient()
metrics = MetricsService()
service = _make_service(
client=client, subscription_repo=subscription_repo, metrics=metrics
)
service.notification_timers_cache["example.com"] = time.monotonic()
await stream_repo.create("example.com")
await subscription_repo.add("example.com", "!room:matrix.org")
await service.notify_stream_live("example.com", "Stream", "Title", [])
output = generate_metrics_output(metrics)
assert 'result="success",type="live"} 0.0' in output