# 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 bot command handlers.""" import json from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING import pytest import time_machine from aioresponses import aioresponses from owncastsentry.commands import ( _SECONDS_PER_DAY, _SECONDS_PER_HOUR, _SECONDS_PER_MINUTE, _escape_markdown, _format_duration, _sanitize_for_markdown, ) from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD from tests.conftest import VALID_STATUS_RESPONSE if TYPE_CHECKING: from maubot.testing.bot import TestBot from owncastsentry import OwncastSentry _VALID_STATUS_BODY = json.dumps(VALID_STATUS_RESPONSE).encode() async def _subscribe_valid_stream( maubot_test_bot: TestBot, domain: str = "stream.logal.dev", *, room_id: str | None = None, ) -> None: """Subscribe to a domain with a mocked valid Owncast status response.""" with aioresponses() as mocked: mocked.get( f"https://{domain}{_OWNCAST_STATUS_PATH}", body=_VALID_STATUS_BODY, ) if room_id is None: await maubot_test_bot.send(f"!subscribe {domain}") else: await maubot_test_bot.send(f"!subscribe {domain}", room_id=room_id) async def _subscribe_valid_streams( maubot_test_bot: TestBot, *domains: str, ) -> None: """Subscribe to multiple domains with mocked valid Owncast status responses.""" with aioresponses() as mocked: for domain in domains: mocked.get( f"https://{domain}{_OWNCAST_STATUS_PATH}", body=_VALID_STATUS_BODY, ) for domain in domains: await maubot_test_bot.send(f"!subscribe {domain}") async def _set_stream_state( maubot_plugin: OwncastSentry, domain: str = "stream.logal.dev", *, name: str | None = None, title: str | None = None, online: bool = False, status_since: str | None = None, ) -> None: """Write stream state used by command rendering tests.""" await maubot_plugin.stream_repo.update( domain, name=name, title=title, online=online, status_since=status_since, ) class TestEscapeMarkdown: """Markdown special character escaping.""" @pytest.mark.parametrize( ("input_text", "expected"), [ pytest.param("hello", "hello", id="plain-text-unchanged"), pytest.param("*bold*", "\\*bold\\*", id="asterisks"), pytest.param("_italic_", "\\_italic\\_", id="underscores"), pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"), pytest.param("`code`", "\\`code\\`", id="backticks"), pytest.param("# heading", "\\# heading", id="heading"), pytest.param("> quote", "\\> quote", id="blockquote"), pytest.param("", "\\", id="angle-brackets"), pytest.param("a & b", "a \\& b", id="ampersand"), pytest.param("a\\b", "a\\\\b", id="backslash"), pytest.param("", "", id="empty-string"), ], ) def test_escapes_special_chars(self, input_text: str, expected: str) -> None: """Escape Markdown special characters in input text.""" assert _escape_markdown(input_text) == expected class TestSanitizeForMarkdown: """Markdown sanitization combining whitespace normalization and escaping.""" def test_removes_newlines_and_escapes(self) -> None: """Normalize newlines and escape Markdown special characters.""" result = _sanitize_for_markdown("*bold*\nnew line") assert result == "\\*bold\\* new line" def test_empty_string(self) -> None: """Return empty string unchanged.""" assert _sanitize_for_markdown("") == "" class TestFormatDuration: """Elapsed time calculation from ISO timestamps.""" _NOW = datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC) @pytest.mark.parametrize( ("seconds_ago", "expected"), [ pytest.param(1, "1 second", id="singular-second"), pytest.param(30, "30 seconds", id="plural-seconds"), pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"), pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"), pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"), pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"), pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"), pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"), ], ) def test_formats_duration(self, seconds_ago: int, expected: str) -> None: """Format a timestamp into a human-readable duration.""" timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat() result = _format_duration(timestamp, self._NOW) assert result == expected def test_invalid_timestamp(self) -> None: """Return 'unknown duration' for unparsable timestamps.""" assert _format_duration("not-a-timestamp", self._NOW) == "unknown duration" def test_naive_timestamp(self) -> None: """Return 'unknown duration' for timestamps without timezone information.""" assert _format_duration("2026-03-13T11:59:00", self._NOW) == "unknown duration" def test_future_timestamp(self) -> None: """Return 'unknown duration' for timestamps in the future.""" timestamp = (self._NOW + timedelta(seconds=1)).isoformat() assert _format_duration(timestamp, self._NOW) == "unknown duration" @pytest.mark.usefixtures("maubot_plugin") class TestSubscribeCommand: """Subscribe command end-to-end via maubot.""" async def test_subscribe_valid_stream(self, maubot_test_bot: TestBot) -> None: """Subscribe to a valid Owncast stream.""" await _subscribe_valid_stream(maubot_test_bot) assert len(maubot_test_bot.responded) == 1 assert maubot_test_bot.responded[0].content.body == ( "Subscription added! This room will receive notifications when " "stream.logal.dev goes live." ) async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None: """Reject subscription to an invalid Owncast instance.""" status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}" with aioresponses() as mocked: mocked.get(status_url, status=404) await maubot_test_bot.send("!subscribe invalid.com") assert len(maubot_test_bot.responded) == 1 assert maubot_test_bot.responded[0].content.body == ( "The URL you supplied does not appear to " "be a valid Owncast instance. You may have " "specified an invalid domain, or the " "instance is offline." ) async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None: """Reject duplicate subscription in the same room.""" await _subscribe_valid_stream(maubot_test_bot) # Second subscribe; stream already exists so validation is skipped await maubot_test_bot.send("!subscribe stream.logal.dev") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "This room is already subscribed to notifications for stream.logal.dev." ) async def test_subscribe_existing_stream_new_room( self, maubot_test_bot: TestBot ) -> None: """Skip validation when the domain already has subscriptions.""" await _subscribe_valid_stream(maubot_test_bot) # Subscribe from a different room. The existing subscribed domain skips # validation, so an empty aioresponses context will raise ConnectionError # if any HTTP request is attempted. with aioresponses(): await maubot_test_bot.send( "!subscribe stream.logal.dev", room_id="!otherroom:example.com" ) assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "Subscription added! This room will receive notifications when " "stream.logal.dev goes live." ) @pytest.mark.usefixtures("maubot_plugin") class TestUnsubscribeCommand: """Unsubscribe command end-to-end via maubot.""" async def test_unsubscribe_existing(self, maubot_test_bot: TestBot) -> None: """Unsubscribe from a subscribed stream.""" await _subscribe_valid_stream(maubot_test_bot) await maubot_test_bot.send("!unsubscribe stream.logal.dev") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "Subscription removed! This room will no " "longer receive notifications for stream.logal.dev." ) async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None: """Reject unsubscribe from a non-subscribed stream.""" await maubot_test_bot.send("!unsubscribe unknown.com") assert len(maubot_test_bot.responded) == 1 assert maubot_test_bot.responded[0].content.body == ( "This room is already not subscribed to notifications for unknown.com." ) @pytest.mark.usefixtures("maubot_plugin") class TestSubscriptionsCommand: """Subscriptions listing command end-to-end via maubot.""" async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None: """Show help text when no subscriptions exist.""" await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 1 assert maubot_test_bot.responded[0].content.body == ( "This room is not subscribed to any Owncast " "instances.\n\nTo subscribe to an Owncast " "instance, use `!subscribe `" ) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_shows_online_stream( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Show stream details including title and duration.""" await _subscribe_valid_stream(maubot_test_bot) await _set_stream_state( maubot_plugin, name="Test Stream", title="Playing Games", online=True, status_since="2026-01-01T12:00:00+00:00", ) await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "**Subscriptions for this room (1):**\n\n" "● **Test Stream** \n" " ○ Title: Playing Games\n" " ○ Status: Online for 71 days\n" " ○ Link: https://stream.logal.dev\n" "To unsubscribe from any of these Owncast " "instances, use `!unsubscribe `" ) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_escapes_markdown_in_stream_name_and_title( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Render stream name and title as literal text in command output.""" await _subscribe_valid_stream(maubot_test_bot) await _set_stream_state( maubot_plugin, name="*Bold* [link](https://evil.example)\nName", title="`code` > quote #tag", online=True, status_since="2026-01-01T12:00:00+00:00", ) await maubot_test_bot.send("!subscriptions") content = maubot_test_bot.responded[1].content assert "● ***Bold* [link](https://evil.example) Name**" in content.body assert " ○ Title: `code` > quote #tag" in content.body assert content.formatted_body is not None assert '' not in content.formatted_body assert ( "*Bold* [link](https://evil.example) Name" in content.formatted_body ) assert "Title: `code` > quote #tag" in content.formatted_body @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_shows_offline_stream( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Show offline status for non-live streams.""" await _subscribe_valid_stream(maubot_test_bot) await _set_stream_state( maubot_plugin, name="Test Stream", status_since="2026-01-01T10:00:00+00:00", ) await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "**Subscriptions for this room (1):**\n\n" "● **Test Stream** \n" " ○ Status: Offline for 71 days\n" " ○ Link: https://stream.logal.dev\n" "To unsubscribe from any of these Owncast " "instances, use `!unsubscribe `" ) async def test_shows_offline_stream_without_disconnect_time( self, maubot_test_bot: TestBot ) -> None: """Show offline status without duration before first poll completes.""" await _subscribe_valid_stream(maubot_test_bot) # Stream row exists with no state yet - query subscriptions immediately await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "**Subscriptions for this room (1):**\n\n" "● **stream.logal.dev** \n" " ○ Status: Offline\n" " ○ Link: https://stream.logal.dev\n" "To unsubscribe from any of these Owncast " "instances, use `!unsubscribe `" ) async def test_shows_unknown_stream( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Show unknown status when instance has been unreachable.""" await _subscribe_valid_stream(maubot_test_bot) # Increment failure counter past the unknown threshold for _ in range(UNKNOWN_STATUS_THRESHOLD + 1): await maubot_plugin.stream_repo.increment_failure_counter( "stream.logal.dev" ) await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "**Subscriptions for this room (1):**\n\n" "● **stream.logal.dev** \n" " ○ Status: Unknown (instance unreachable)\n" " ○ Link: https://stream.logal.dev\n" "To unsubscribe from any of these Owncast " "instances, use `!unsubscribe `" ) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_shows_multiple_subscriptions( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """List subscriptions ordered by domain with mixed statuses.""" # Subscribe in reverse domain order to verify domain-sorted output await _subscribe_valid_streams(maubot_test_bot, "beta.com", "alpha.com") await _set_stream_state( maubot_plugin, "alpha.com", name="Alpha Stream", title="Streaming Live", online=True, status_since="2026-03-13T10:00:00+00:00", ) await _set_stream_state( maubot_plugin, "beta.com", name="Beta Stream", status_since="2026-03-12T18:00:00+00:00", ) await maubot_test_bot.send("!subscriptions") assert len(maubot_test_bot.responded) == 3 assert maubot_test_bot.responded[2].content.body == ( "**Subscriptions for this room (2):**\n\n" "● **Alpha Stream**\n" " \n" " ○ Title: Streaming Live\n" " ○ Status: Online for 2 hours\n" " ○ Link: https://alpha.com\n" "● **Beta Stream**\n" " \n" " ○ Status: Offline for 18 hours\n" " ○ Link: https://beta.com\n" "To unsubscribe from any of these Owncast " "instances, use `!unsubscribe `" ) @pytest.mark.usefixtures("maubot_plugin") class TestLiveCommand: """Live streams listing command end-to-end via maubot.""" async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None: """Show help text when no subscriptions exist.""" await maubot_test_bot.send("!live") assert len(maubot_test_bot.responded) == 1 assert maubot_test_bot.responded[0].content.body == ( "This room is not subscribed to any Owncast " "instances.\n\nTo subscribe to an Owncast " "instance, use `!subscribe `" ) async def test_no_live_streams( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Show 'no live' message when all streams are offline.""" await _subscribe_valid_stream(maubot_test_bot) await _set_stream_state( maubot_plugin, name="Test Stream", status_since="2026-01-01T10:00:00+00:00", ) await maubot_test_bot.send("!live") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "No subscribed Owncast instances are currently " "live.\n\nUse `!subscriptions` to list all " "subscriptions." ) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_shows_live_stream( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """Show live stream with title and duration.""" await _subscribe_valid_stream(maubot_test_bot) await _set_stream_state( maubot_plugin, name="Test Stream", title="Playing Games", online=True, status_since="2026-01-01T12:00:00+00:00", ) await maubot_test_bot.send("!live") assert len(maubot_test_bot.responded) == 2 assert maubot_test_bot.responded[1].content.body == ( "**Live Owncast instances (1):**\n\n" "● **Test Stream** \n" " ○ Title: Playing Games\n" " ○ Online for 71 days\n" " ○ Link: https://stream.logal.dev" ) @time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC)) async def test_shows_multiple_live_streams( self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry ) -> None: """List live streams ordered by domain with different durations.""" # Subscribe in reverse domain order to verify domain-sorted output await _subscribe_valid_streams(maubot_test_bot, "beta.com", "alpha.com") await _set_stream_state( maubot_plugin, "alpha.com", name="Alpha Stream", title="Morning Show", online=True, status_since="2026-03-13T10:00:00+00:00", ) await _set_stream_state( maubot_plugin, "beta.com", name="Beta Stream", title="Evening Vibes", online=True, status_since="2026-03-13T06:00:00+00:00", ) await maubot_test_bot.send("!live") assert len(maubot_test_bot.responded) == 3 assert maubot_test_bot.responded[2].content.body == ( "**Live Owncast instances (2):**\n\n" "● **Alpha Stream**\n" " \n" " ○ Title: Morning Show\n" " ○ Online for 2 hours\n" " ○ Link: https://alpha.com\n" "● **Beta Stream**\n" " \n" " ○ Title: Evening Vibes\n" " ○ Online for 6 hours\n" " ○ Link: https://beta.com" )