# 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