Files
OwncastSentry/tests/test_commands.py
T
LogalDeveloper c21a8d073a
CI / Formatting (push) Successful in 31s
CI / Linting (push) Successful in 10s
CI / Tests (push) Successful in 35s
CI / Type Checking (push) Successful in 8s
CI / Spelling (push) Successful in 5s
Moved stream subscription lifecycle handling into SQLite.
2026-05-25 19:18:20 -04:00

541 lines
20 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 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("<html>", "\\<html\\>", 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 <domain>`"
)
@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 <domain>`"
)
@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 '<a href="https://evil.example">' not in content.formatted_body
assert (
"<strong>*Bold* [link](https://evil.example) Name</strong>"
in content.formatted_body
)
assert "Title: `code` &gt; 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 <domain>`"
)
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 <domain>`"
)
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 <domain>`"
)
@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 <domain>`"
)
@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 <domain>`"
)
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"
)