Refactored OwncastSentry internals and API validation.
This commit is contained in:
+6
-3
@@ -23,15 +23,18 @@ from prometheus_client import generate_latest
|
||||
|
||||
from owncastsentry import OwncastSentry
|
||||
from owncastsentry.config import Config
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
from owncastsentry.migrations import get_upgrade_table
|
||||
from owncastsentry.repository import (
|
||||
StreamRepository,
|
||||
SubscriptionRepository,
|
||||
get_upgrade_table,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
from pathlib import Path
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.models import StreamConfig, StreamState
|
||||
from owncastsentry.types import StreamConfig, StreamState
|
||||
|
||||
|
||||
def generate_metrics_output(metrics: MetricsService) -> str:
|
||||
|
||||
+102
-32
@@ -15,28 +15,57 @@
|
||||
"""Tests for bot command handlers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.commands import CommandHandler
|
||||
from owncastsentry.models import StreamState
|
||||
from owncastsentry.utils import OWNCAST_STATUS_PATH, UNKNOWN_STATUS_THRESHOLD
|
||||
from owncastsentry.commands import (
|
||||
_escape_markdown,
|
||||
_format_duration,
|
||||
_sanitize_for_markdown,
|
||||
)
|
||||
from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
|
||||
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
|
||||
from tests.conftest import VALID_STATUS_RESPONSE
|
||||
|
||||
|
||||
def _make_command_handler() -> CommandHandler:
|
||||
"""Build a CommandHandler with dummy dependencies for pure logic tests."""
|
||||
return CommandHandler(
|
||||
owncast_client=MagicMock(),
|
||||
stream_repo=MagicMock(),
|
||||
subscription_repo=MagicMock(),
|
||||
logger=logging.getLogger("test"),
|
||||
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 the given Markdown special character."""
|
||||
assert _escape_markdown(input_text) == expected
|
||||
|
||||
|
||||
class TestSanitizeForMarkdown:
|
||||
"""Markdown sanitization combining newline removal and escaping."""
|
||||
|
||||
def test_removes_newlines_and_escapes(self) -> None:
|
||||
"""Remove 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:
|
||||
@@ -57,18 +86,24 @@ class TestFormatDuration:
|
||||
pytest.param(172800, "2 days", id="plural-days"),
|
||||
],
|
||||
)
|
||||
@time_machine.travel(_NOW)
|
||||
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
||||
"""Format a timestamp into a human-readable duration."""
|
||||
handler = _make_command_handler()
|
||||
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
|
||||
result = handler._format_duration(timestamp)
|
||||
result = _format_duration(timestamp, self._NOW)
|
||||
assert result == expected
|
||||
|
||||
def test_invalid_timestamp(self) -> None:
|
||||
"""Return 'unknown duration' for unparsable timestamps."""
|
||||
handler = _make_command_handler()
|
||||
assert handler._format_duration("not-a-timestamp") == "unknown duration"
|
||||
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"
|
||||
|
||||
|
||||
class TestSubscribeCommand:
|
||||
@@ -76,7 +111,7 @@ class TestSubscribeCommand:
|
||||
|
||||
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Subscribe to a valid Owncast stream."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -94,7 +129,7 @@ class TestSubscribeCommand:
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Reject subscription to an invalid Owncast instance."""
|
||||
status_url = f"https://invalid.com{OWNCAST_STATUS_PATH}"
|
||||
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")
|
||||
@@ -111,7 +146,7 @@ class TestSubscribeCommand:
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Reject duplicate subscription in the same room."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -131,7 +166,7 @@ class TestSubscribeCommand:
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Skip instance validation when subscribing from a new room."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -159,7 +194,7 @@ class TestUnsubscribeCommand:
|
||||
|
||||
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Unsubscribe from a subscribed stream."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -205,7 +240,7 @@ class TestSubscriptionsCommand:
|
||||
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show stream details including title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -236,11 +271,46 @@ class TestSubscriptionsCommand:
|
||||
"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, maubot_plugin
|
||||
) -> None:
|
||||
"""Render stream name and title as literal text in command output."""
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="*Bold* [link](https://evil.example)\nName",
|
||||
title="`code` > quote #tag",
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
)
|
||||
|
||||
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` > 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, maubot_plugin) -> None:
|
||||
"""Show offline status for non-live streams."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -273,7 +343,7 @@ class TestSubscriptionsCommand:
|
||||
self, maubot_test_bot, maubot_plugin
|
||||
) -> None:
|
||||
"""Show offline status without duration before first poll completes."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -296,7 +366,7 @@ class TestSubscriptionsCommand:
|
||||
|
||||
async def test_shows_unknown_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show unknown status when instance has been unreachable."""
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -330,11 +400,11 @@ class TestSubscriptionsCommand:
|
||||
# Subscribe in reverse alphabetical order to verify sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
||||
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
||||
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
@@ -393,7 +463,7 @@ class TestLiveCommand:
|
||||
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show 'no live' message when all streams are offline."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -423,7 +493,7 @@ class TestLiveCommand:
|
||||
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
||||
"""Show live stream with title and duration."""
|
||||
# Subscribe first
|
||||
status_url = f"https://stream.logal.dev{OWNCAST_STATUS_PATH}"
|
||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
status_url,
|
||||
@@ -460,11 +530,11 @@ class TestLiveCommand:
|
||||
# Subscribe in reverse alphabetical order to verify sorted output
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
||||
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
mocked.get(
|
||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
||||
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await maubot_test_bot.send("!subscribe beta.com")
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# 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 database repository classes."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
class TestStreamExists:
|
||||
"""Stream existence checks."""
|
||||
|
||||
async def test_returns_true_for_existing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return True when the stream exists in the database."""
|
||||
await stream_repo.create("example.com")
|
||||
assert await stream_repo.exists("example.com") is True
|
||||
|
||||
async def test_returns_false_for_missing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return False when the stream does not exist in the database."""
|
||||
assert await stream_repo.exists("missing.com") is False
|
||||
|
||||
|
||||
class TestStreamDelete:
|
||||
"""Stream record deletion."""
|
||||
|
||||
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
||||
"""Remove the stream record so get_by_domain returns None."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.delete("example.com")
|
||||
assert await stream_repo.get_by_domain("example.com") is None
|
||||
|
||||
|
||||
class TestGetSubscribedStreamsForRoom:
|
||||
"""Subscribed stream lookup by room."""
|
||||
|
||||
async def test_returns_all_domains_for_room(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return all domains a room is subscribed to."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await stream_repo.create("beta.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("beta.com", "!room1:example.com")
|
||||
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!room1:example.com"
|
||||
)
|
||||
assert sorted(result) == ["alpha.com", "beta.com"]
|
||||
|
||||
async def test_returns_empty_list_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when the room has no subscriptions."""
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestGetAllSubscribedDomains:
|
||||
"""Unique subscribed domain retrieval."""
|
||||
|
||||
async def test_returns_each_domain_once(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return each domain once even with multiple subscriptions."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == ["alpha.com"]
|
||||
|
||||
async def test_returns_empty_list_with_no_subscriptions(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when there are no subscriptions."""
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestCountByDomain:
|
||||
"""Subscription count by domain."""
|
||||
|
||||
async def test_returns_correct_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return the correct subscription count for a domain."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 for a domain with no subscriptions."""
|
||||
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
||||
|
||||
|
||||
class TestDeleteAllForDomain:
|
||||
"""Bulk subscription deletion by domain."""
|
||||
|
||||
async def test_deletes_all_subscriptions_and_returns_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Delete all subscriptions for the domain and return the count."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
||||
assert deleted == 2
|
||||
|
||||
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
||||
assert rooms == []
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 when deleting subscriptions for an unknown domain."""
|
||||
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|
||||
@@ -17,7 +17,7 @@
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
|
||||
from owncastsentry.models import StreamStatus
|
||||
from owncastsentry.types import StreamStatus
|
||||
from tests.conftest import generate_metrics_output
|
||||
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# 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 data models."""
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
||||
from owncastsentry.utils import (
|
||||
MAX_INSTANCE_TITLE_LENGTH,
|
||||
MAX_STREAM_TITLE_LENGTH,
|
||||
MAX_TAG_LENGTH,
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
class TestStreamStateStatus:
|
||||
"""Stream status derivation from state fields."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_counter", "last_connect_time", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
None,
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-offline-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-online-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="zero-failures-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="zero-failures-no-connect-time-returns-offline",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="at-threshold-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="at-threshold-no-connect-time-returns-offline",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_status(
|
||||
self,
|
||||
failure_counter: int,
|
||||
last_connect_time: str | None,
|
||||
expected: StreamStatus,
|
||||
) -> None:
|
||||
"""Return the correct status based on failure counter and connect time."""
|
||||
state = StreamState(
|
||||
domain="example.com",
|
||||
failure_counter=failure_counter,
|
||||
last_connect_time=last_connect_time,
|
||||
)
|
||||
assert state.status is expected
|
||||
|
||||
|
||||
class TestStreamStateFromApiResponse:
|
||||
"""StreamState construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate all fields from a complete API response."""
|
||||
response = {
|
||||
"streamTitle": "My Stream",
|
||||
"lastConnectTime": "2026-01-01T00:00:00Z",
|
||||
"lastDisconnectTime": "2025-12-31T23:00:00Z",
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert state.domain == "example.com"
|
||||
assert state.title == "My Stream"
|
||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
||||
assert state.name is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
def test_empty_response_defaults(self) -> None:
|
||||
"""Use defaults when optional fields are missing."""
|
||||
state = StreamState.from_api_response({}, "bare.example.com")
|
||||
assert state.domain == "bare.example.com"
|
||||
assert state.title == ""
|
||||
assert state.last_connect_time is None
|
||||
assert state.last_disconnect_time is None
|
||||
|
||||
def test_title_truncation(self) -> None:
|
||||
"""Truncate the stream title to MAX_STREAM_TITLE_LENGTH."""
|
||||
long_title = "A" * (MAX_STREAM_TITLE_LENGTH + 50)
|
||||
response = {"streamTitle": long_title}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert len(state.title) == MAX_STREAM_TITLE_LENGTH
|
||||
assert state.title == "A" * MAX_STREAM_TITLE_LENGTH
|
||||
|
||||
|
||||
class TestStreamStateFromDbRow:
|
||||
"""StreamState construction from a database row dictionary."""
|
||||
|
||||
def test_typical_row(self) -> None:
|
||||
"""Populate all fields from a complete database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": "Test Instance",
|
||||
"title": "Live Now",
|
||||
"last_connect_time": "2026-01-01T00:00:00Z",
|
||||
"last_disconnect_time": "2025-12-31T23:00:00Z",
|
||||
"failure_counter": 3,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name == "Test Instance"
|
||||
assert state.title == "Live Now"
|
||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
||||
assert state.failure_counter == 3
|
||||
|
||||
def test_row_with_none_optional_fields(self) -> None:
|
||||
"""Accept None for optional fields in a database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": None,
|
||||
"title": None,
|
||||
"last_connect_time": None,
|
||||
"last_disconnect_time": None,
|
||||
"failure_counter": 0,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name is None
|
||||
assert state.title is None
|
||||
assert state.last_connect_time is None
|
||||
assert state.last_disconnect_time is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
|
||||
class TestStreamConfigFromApiResponse:
|
||||
"""StreamConfig construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate name and tags from a complete API response."""
|
||||
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert config.name == "My Instance"
|
||||
assert config.tags == ["gaming", "music"]
|
||||
|
||||
def test_missing_keys_defaults(self) -> None:
|
||||
"""Use defaults when name and tags keys are missing."""
|
||||
config = StreamConfig.from_api_response({})
|
||||
assert config.name == ""
|
||||
assert config.tags == []
|
||||
|
||||
def test_name_truncation(self) -> None:
|
||||
"""Truncate the instance name to MAX_INSTANCE_TITLE_LENGTH."""
|
||||
long_name = "B" * (MAX_INSTANCE_TITLE_LENGTH + 50)
|
||||
response = {"name": long_name, "tags": []}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert len(config.name) == MAX_INSTANCE_TITLE_LENGTH
|
||||
assert config.name == "B" * MAX_INSTANCE_TITLE_LENGTH
|
||||
|
||||
def test_tag_truncation(self) -> None:
|
||||
"""Truncate each tag to MAX_TAG_LENGTH."""
|
||||
long_tag = "C" * (MAX_TAG_LENGTH + 10)
|
||||
response = {"name": "", "tags": [long_tag, "short"]}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert len(config.tags[0]) == MAX_TAG_LENGTH
|
||||
assert config.tags[0] == "C" * MAX_TAG_LENGTH
|
||||
assert config.tags[1] == "short"
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
"""Tests for the notification service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -21,12 +22,15 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS
|
||||
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.database import StreamRepository, SubscriptionRepository
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
def _make_service(
|
||||
@@ -44,6 +48,27 @@ def _make_service(
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@@ -75,7 +100,7 @@ class TestCanNotify:
|
||||
)
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
service.notification_timers_cache["example.com"] = (
|
||||
time.monotonic() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
time.monotonic() - _SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
assert service._can_notify("example.com") is True
|
||||
|
||||
@@ -103,6 +128,35 @@ class TestGetLastNotificationTime:
|
||||
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."""
|
||||
|
||||
@@ -246,6 +300,74 @@ class TestNotifyStreamLive:
|
||||
for msg in client.sent_messages:
|
||||
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_message(_room_id: str, _content: object) -> None:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
client.send_message = send_message
|
||||
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,
|
||||
|
||||
+231
-27
@@ -22,7 +22,12 @@ import pytest
|
||||
from aioresponses import aioresponses
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.owncast_client import OwncastClient
|
||||
from owncastsentry.owncast_client import (
|
||||
_MAX_JSON_RESPONSE_BYTES,
|
||||
OwncastClient,
|
||||
_read_limited_response_body,
|
||||
_user_agent,
|
||||
)
|
||||
from tests.conftest import (
|
||||
VALID_CONFIG_RESPONSE,
|
||||
VALID_STATUS_RESPONSE,
|
||||
@@ -33,6 +38,32 @@ if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
class _ChunkedContent:
|
||||
"""Fake aiohttp response content that yields predefined chunks."""
|
||||
|
||||
def __init__(self, chunks: tuple[bytes, ...]) -> None:
|
||||
"""Store chunks to return from iter_chunked."""
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
|
||||
"""Yield chunks using the interface aiohttp exposes."""
|
||||
for chunk in self._chunks:
|
||||
yield chunk
|
||||
|
||||
|
||||
class _ChunkedResponse:
|
||||
"""Fake aiohttp response with chunked content."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chunks: tuple[bytes, ...],
|
||||
content_length: int | None = None,
|
||||
) -> None:
|
||||
"""Store the content stream and optional Content-Length value."""
|
||||
self.content = _ChunkedContent(chunks)
|
||||
self.content_length = content_length
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
"""Create an OwncastClient and close it after the test."""
|
||||
@@ -45,6 +76,79 @@ async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""User-Agent header construction."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"1.2.3",
|
||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="semver",
|
||||
),
|
||||
pytest.param(
|
||||
"0.0.0",
|
||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="zeroed",
|
||||
),
|
||||
pytest.param(
|
||||
"1.1.1.dev10+gf0146d061.d20260313",
|
||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="dev-version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_user_agent(self, version: str, expected: str) -> None:
|
||||
"""Build a correctly formatted User-Agent header."""
|
||||
assert _user_agent(version) == expected
|
||||
|
||||
|
||||
class TestReadLimitedResponseBody:
|
||||
"""Bounded response body reading."""
|
||||
|
||||
async def test_reads_all_chunks_before_returning(self) -> None:
|
||||
"""Return the full body when JSON arrives in multiple chunks."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b'{"streamTitle":',
|
||||
b'"hello","online":true,',
|
||||
b'"lastConnectTime":null,"lastDisconnectTime":null}',
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result == bytearray(
|
||||
b'{"streamTitle":"hello","online":true,'
|
||||
b'"lastConnectTime":null,"lastDisconnectTime":null}'
|
||||
)
|
||||
|
||||
async def test_returns_none_when_content_length_is_too_large(self) -> None:
|
||||
"""Return None when Content-Length is already over the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(),
|
||||
content_length=_MAX_JSON_RESPONSE_BYTES + 1,
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_when_streamed_body_is_too_large(self) -> None:
|
||||
"""Return None when chunked content grows past the limit."""
|
||||
response = _ChunkedResponse(
|
||||
(
|
||||
b"x" * _MAX_JSON_RESPONSE_BYTES,
|
||||
b"x",
|
||||
)
|
||||
)
|
||||
|
||||
result = await _read_limited_response_body(response)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestGetStreamState:
|
||||
"""Stream state retrieval from the status API."""
|
||||
|
||||
@@ -82,6 +186,23 @@ class TestGetStreamState:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_STATUS_RESPONSE,
|
||||
"streamTitle": 123,
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
@@ -95,6 +216,32 @@ class TestGetStreamState:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
@@ -136,7 +283,7 @@ class TestGetStreamConfig:
|
||||
|
||||
assert result is not None
|
||||
assert result.name == "LogalDeveloper's Live Stream"
|
||||
assert result.tags == [
|
||||
assert result.tags == (
|
||||
"video games",
|
||||
"chatting",
|
||||
"casual",
|
||||
@@ -144,7 +291,7 @@ class TestGetStreamConfig:
|
||||
"streaming",
|
||||
"owncast",
|
||||
"variety",
|
||||
]
|
||||
)
|
||||
|
||||
async def test_returns_none_on_invalid_json(
|
||||
self, owncast_client: OwncastClient
|
||||
@@ -159,6 +306,49 @@ class TestGetStreamConfig:
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_object_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response JSON is not an object."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps([]).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_invalid_field_type(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response has malformed field types."""
|
||||
malformed = {
|
||||
**VALID_CONFIG_RESPONSE,
|
||||
"tags": "gaming",
|
||||
}
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=json.dumps(malformed).encode(),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_oversized_json(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return None when the response body is too large."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||
)
|
||||
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||
|
||||
assert result is None
|
||||
|
||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||
"""Return None when the response status is not 200."""
|
||||
with aioresponses() as mocked:
|
||||
@@ -225,15 +415,19 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_failure(self) -> None:
|
||||
"""Do not record response time when request fails."""
|
||||
@@ -243,15 +437,20 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
status=500,
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}'
|
||||
not in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
async def test_no_observation_on_connection_error(self) -> None:
|
||||
"""Do not record response time on connection error."""
|
||||
@@ -261,15 +460,20 @@ class TestResponseTimeMetrics:
|
||||
version="0.0.0",
|
||||
metrics=metrics,
|
||||
)
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
try:
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://example.com/api/status",
|
||||
exception=ConnectionError(),
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert (
|
||||
'owncastsentry_api_response_seconds{domain="example.com"}'
|
||||
not in output
|
||||
)
|
||||
await client.get_stream_state("example.com")
|
||||
output = generate_metrics_output(metrics)
|
||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||
await client.close()
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestOpenConnectionCount:
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# 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 database repository classes."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
NotSubscribedError,
|
||||
StreamState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
class TestStreamExists:
|
||||
"""Stream existence checks."""
|
||||
|
||||
async def test_returns_true_for_existing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return True when the stream exists in the database."""
|
||||
await stream_repo.create("example.com")
|
||||
assert await stream_repo.exists("example.com") is True
|
||||
|
||||
async def test_returns_false_for_missing_stream(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return False when the stream does not exist in the database."""
|
||||
assert await stream_repo.exists("missing.com") is False
|
||||
|
||||
|
||||
class TestStreamCreate:
|
||||
"""Stream creation behavior."""
|
||||
|
||||
async def test_returns_true_when_created(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return True when a stream row is inserted."""
|
||||
assert await stream_repo.create("example.com") is True
|
||||
|
||||
async def test_returns_false_when_existing(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Return False when a stream row already exists."""
|
||||
await stream_repo.create("example.com")
|
||||
assert await stream_repo.create("example.com") is False
|
||||
|
||||
|
||||
class TestStreamDelete:
|
||||
"""Stream record deletion."""
|
||||
|
||||
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
||||
"""Remove the stream record so get_by_domain returns None."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.delete("example.com")
|
||||
assert await stream_repo.get_by_domain("example.com") is None
|
||||
|
||||
|
||||
class TestGetSubscribedStreamsForRoom:
|
||||
"""Subscribed stream lookup by room."""
|
||||
|
||||
async def test_returns_all_domains_for_room(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return all domains a room is subscribed to."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await stream_repo.create("beta.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("beta.com", "!room1:example.com")
|
||||
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!room1:example.com"
|
||||
)
|
||||
assert sorted(result) == ["alpha.com", "beta.com"]
|
||||
|
||||
async def test_returns_empty_list_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when the room has no subscriptions."""
|
||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestHasRoomSubscriptions:
|
||||
"""Room subscription existence checks."""
|
||||
|
||||
async def test_returns_true_for_subscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return True when the room has at least one subscription."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
|
||||
assert await subscription_repo.has_room_subscriptions("!room1:example.com")
|
||||
|
||||
async def test_returns_false_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return False when the room has no subscriptions."""
|
||||
assert not await subscription_repo.has_room_subscriptions(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
|
||||
|
||||
class TestGetRoomSubscriptions:
|
||||
"""Resolved room subscription lookup."""
|
||||
|
||||
async def test_returns_sorted_stream_states_and_skips_missing_rows(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return sorted resolved subscriptions and skip missing stream rows."""
|
||||
await stream_repo.create("beta.example")
|
||||
await stream_repo.update(StreamState(domain="beta.example", name="Beta"))
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update(StreamState(domain="alpha.example", name="Alpha"))
|
||||
await subscription_repo.add("beta.example", "!room:example.com")
|
||||
await subscription_repo.add("missing.example", "!room:example.com")
|
||||
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"alpha.example",
|
||||
"beta.example",
|
||||
]
|
||||
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||
"Alpha",
|
||||
"Beta",
|
||||
]
|
||||
|
||||
async def test_returns_empty_list_for_unsubscribed_room(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when the room has no resolved subscriptions."""
|
||||
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||
"!nobody:example.com"
|
||||
)
|
||||
assert subscriptions == []
|
||||
|
||||
|
||||
class TestGetLiveRoomSubscriptions:
|
||||
"""Resolved live room subscription lookup."""
|
||||
|
||||
async def test_returns_online_streams_and_skips_inactive_states(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return only streams whose derived status is ONLINE."""
|
||||
await stream_repo.create("offline.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="offline.example",
|
||||
name="Offline",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="online.example",
|
||||
name="Online",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="unknown.example",
|
||||
name="Unknown",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
await subscription_repo.add("online.example", "!room:example.com")
|
||||
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"online.example"
|
||||
]
|
||||
|
||||
async def test_returns_empty_list_for_room_with_no_live_streams(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return an empty list when no subscribed streams are live."""
|
||||
await stream_repo.create("offline.example")
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
|
||||
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||
"!room:example.com"
|
||||
)
|
||||
|
||||
assert subscriptions == []
|
||||
|
||||
|
||||
class TestAddSubscription:
|
||||
"""Subscription creation behavior."""
|
||||
|
||||
async def test_adds_subscription(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Add a subscription row."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
assert await subscription_repo.get_subscribed_rooms("alpha.com") == [
|
||||
"!room1:example.com"
|
||||
]
|
||||
|
||||
async def test_raises_when_existing(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Raise AlreadySubscribedError when a subscription already exists."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
with pytest.raises(AlreadySubscribedError):
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
|
||||
|
||||
class TestRemoveSubscription:
|
||||
"""Subscription removal behavior."""
|
||||
|
||||
async def test_removes_subscription(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Remove an existing subscription row."""
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||
assert await subscription_repo.get_subscribed_rooms("alpha.com") == []
|
||||
|
||||
async def test_raises_when_missing(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Raise NotSubscribedError when no subscription exists."""
|
||||
with pytest.raises(NotSubscribedError):
|
||||
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||
|
||||
|
||||
class TestGetAllSubscribedDomains:
|
||||
"""Unique subscribed domain retrieval."""
|
||||
|
||||
async def test_returns_each_domain_once(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return each domain once even with multiple subscriptions."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == ["alpha.com"]
|
||||
|
||||
async def test_returns_empty_list_with_no_subscriptions(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty list when there are no subscriptions."""
|
||||
result = await subscription_repo.get_all_subscribed_domains()
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestCountByDomain:
|
||||
"""Subscription count by domain."""
|
||||
|
||||
async def test_returns_correct_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return the correct subscription count for a domain."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 for a domain with no subscriptions."""
|
||||
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
||||
|
||||
|
||||
class TestCountByDomains:
|
||||
"""Bulk subscription counts by domain."""
|
||||
|
||||
async def test_returns_counts_for_requested_domains(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return counts for requested domains and zero for missing domains."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await stream_repo.create("beta.com")
|
||||
await stream_repo.create("ignored.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
await subscription_repo.add("beta.com", "!room3:example.com")
|
||||
await subscription_repo.add("ignored.com", "!room4:example.com")
|
||||
|
||||
assert await subscription_repo.count_by_domains(
|
||||
["beta.com", "missing.com", "alpha.com"]
|
||||
) == {
|
||||
"beta.com": 1,
|
||||
"missing.com": 0,
|
||||
"alpha.com": 2,
|
||||
}
|
||||
|
||||
async def test_returns_empty_dict_for_empty_domain_list(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return an empty mapping when no domains are requested."""
|
||||
assert await subscription_repo.count_by_domains([]) == {}
|
||||
|
||||
|
||||
class TestDeleteAllForDomain:
|
||||
"""Bulk subscription deletion by domain."""
|
||||
|
||||
async def test_deletes_all_subscriptions_and_returns_count(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Delete all subscriptions for the domain and return the count."""
|
||||
await stream_repo.create("alpha.com")
|
||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||
|
||||
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
||||
assert deleted == 2
|
||||
|
||||
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
||||
assert rooms == []
|
||||
|
||||
async def test_returns_zero_for_unknown_domain(
|
||||
self, subscription_repo: SubscriptionRepository
|
||||
) -> None:
|
||||
"""Return 0 when deleting subscriptions for an unknown domain."""
|
||||
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|
||||
@@ -18,15 +18,21 @@ import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
||||
from owncastsentry.notification_service import NotificationService
|
||||
from owncastsentry.stream_monitor import StreamMonitor
|
||||
from owncastsentry.utils import (
|
||||
CLEANUP_DELETE_THRESHOLD,
|
||||
CLEANUP_WARNING_THRESHOLD,
|
||||
SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
from owncastsentry.notification_service import (
|
||||
_SECONDS_BETWEEN_NOTIFICATIONS,
|
||||
NotificationService,
|
||||
)
|
||||
from owncastsentry.stream_monitor import (
|
||||
_CLEANUP_DELETE_THRESHOLD,
|
||||
_CLEANUP_WARNING_THRESHOLD,
|
||||
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||
StreamMonitor,
|
||||
_should_query_stream,
|
||||
)
|
||||
from owncastsentry.types import StreamConfig, StreamState, StreamStatus
|
||||
from tests.conftest import (
|
||||
_StubMatrixClient,
|
||||
_StubOwncastClient,
|
||||
@@ -34,7 +40,7 @@ from tests.conftest import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
def _make_monitor(
|
||||
@@ -108,6 +114,37 @@ def _make_monitor_with_metrics(
|
||||
return monitor, notification_service, metrics
|
||||
|
||||
|
||||
class TestShouldQueryStream:
|
||||
"""Progressive backoff logic for stream polling."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("counter", "expected"),
|
||||
[
|
||||
pytest.param(0, True, id="counter-0-always-query"),
|
||||
pytest.param(1, True, id="counter-1-always-query"),
|
||||
pytest.param(4, True, id="counter-4-always-query"),
|
||||
pytest.param(5, False, id="counter-5-skip-odd"),
|
||||
pytest.param(6, True, id="counter-6-query-even"),
|
||||
pytest.param(9, False, id="counter-9-skip-odd"),
|
||||
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
|
||||
pytest.param(12, True, id="counter-12-query-mod-3"),
|
||||
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
|
||||
pytest.param(15, True, id="counter-15-query-mod-5"),
|
||||
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
|
||||
pytest.param(20, True, id="counter-20-query-mod-5"),
|
||||
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
|
||||
pytest.param(30, True, id="counter-30-query-mod-15"),
|
||||
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
|
||||
pytest.param(45, True, id="counter-45-query-mod-15"),
|
||||
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
|
||||
pytest.param(105, True, id="counter-105-query-mod-15"),
|
||||
],
|
||||
)
|
||||
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
|
||||
"""Return the expected query decision for each backoff tier."""
|
||||
assert _should_query_stream(counter) == expected
|
||||
|
||||
|
||||
class TestUpdateAllStreams:
|
||||
"""Parallel stream update orchestration."""
|
||||
|
||||
@@ -252,7 +289,7 @@ class TestUpdateStreamGoesLive:
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
),
|
||||
stream_config=StreamConfig(name="Live Stream", tags=["gaming"]),
|
||||
stream_config=StreamConfig(name="Live Stream", tags=("gaming",)),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -271,7 +308,9 @@ class TestUpdateStreamGoesLive:
|
||||
)
|
||||
|
||||
# Set offline timer to long ago so it's not a brief outage
|
||||
monitor.offline_timer_cache["live.com"] = 0
|
||||
monitor.offline_timer_cache["live.com"] = (
|
||||
time.monotonic() - _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("live.com")
|
||||
assert result is True
|
||||
@@ -315,7 +354,9 @@ class TestUpdateStreamGoesLive:
|
||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
||||
)
|
||||
|
||||
monitor.offline_timer_cache["live.com"] = 0
|
||||
monitor.offline_timer_cache["live.com"] = (
|
||||
time.monotonic() - _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("live.com")
|
||||
assert result is True
|
||||
@@ -447,10 +488,13 @@ class TestUpdateStreamTitleChange:
|
||||
last_connect_time="2026-01-01T12:00:00Z",
|
||||
)
|
||||
|
||||
monitor.offline_timer_cache["title.com"] = 0
|
||||
now = time.monotonic()
|
||||
monitor.offline_timer_cache["title.com"] = (
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 100
|
||||
)
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
time.monotonic() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
@@ -496,10 +540,13 @@ class TestUpdateStreamTitleChange:
|
||||
|
||||
# Last notification was long enough ago to pass rate limiting,
|
||||
# but more recent than the offline timer (so title-change fires)
|
||||
monitor.offline_timer_cache["title.com"] = 0
|
||||
now = time.monotonic()
|
||||
monitor.offline_timer_cache["title.com"] = (
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 100
|
||||
)
|
||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
time.monotonic() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
@@ -546,10 +593,10 @@ class TestUpdateStreamTitleChange:
|
||||
# and both are old enough to pass rate limiting
|
||||
now = time.monotonic()
|
||||
monitor.offline_timer_cache["title.com"] = (
|
||||
now - SECONDS_BETWEEN_NOTIFICATIONS - 100
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 100
|
||||
)
|
||||
notification_service.notification_timers_cache["title.com"] = (
|
||||
now - SECONDS_BETWEEN_NOTIFICATIONS - 200
|
||||
now - _SECONDS_BETWEEN_NOTIFICATIONS - 200
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("title.com")
|
||||
@@ -659,7 +706,7 @@ class TestCheckCleanupThresholds:
|
||||
|
||||
await _seed_stream(stream_repo, subscription_repo, domain="warn.com")
|
||||
|
||||
await monitor._check_cleanup_thresholds("warn.com", CLEANUP_WARNING_THRESHOLD)
|
||||
await monitor._check_cleanup_thresholds("warn.com", _CLEANUP_WARNING_THRESHOLD)
|
||||
|
||||
assert len(client.sent_messages) == 1
|
||||
assert client.sent_messages[0].content.body == (
|
||||
@@ -680,7 +727,7 @@ class TestCheckCleanupThresholds:
|
||||
"""Delete all subscriptions and the stream record at the 90-day threshold."""
|
||||
owncast = _StubOwncastClient()
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
monitor, notification_service = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
@@ -688,8 +735,10 @@ class TestCheckCleanupThresholds:
|
||||
)
|
||||
|
||||
await _seed_stream(stream_repo, subscription_repo, domain="delete.com")
|
||||
monitor.offline_timer_cache["delete.com"] = time.monotonic()
|
||||
notification_service.notification_timers_cache["delete.com"] = time.monotonic()
|
||||
|
||||
await monitor._check_cleanup_thresholds("delete.com", CLEANUP_DELETE_THRESHOLD)
|
||||
await monitor._check_cleanup_thresholds("delete.com", _CLEANUP_DELETE_THRESHOLD)
|
||||
|
||||
# Deletion notification sent
|
||||
assert len(client.sent_messages) == 1
|
||||
@@ -709,6 +758,8 @@ class TestCheckCleanupThresholds:
|
||||
assert await stream_repo.get_by_domain("delete.com") is None
|
||||
rooms = await subscription_repo.get_subscribed_rooms("delete.com")
|
||||
assert rooms == []
|
||||
assert "delete.com" not in monitor.offline_timer_cache
|
||||
assert notification_service.get_last_notification_time("delete.com") == 0
|
||||
|
||||
async def test_no_action_below_thresholds(
|
||||
self,
|
||||
@@ -1061,7 +1112,34 @@ class TestStreamMonitorMetrics:
|
||||
metrics.set_stream_status("delete.com", StreamStatus.OFFLINE)
|
||||
assert 'domain="delete.com"' in generate_metrics_output(metrics)
|
||||
|
||||
await monitor._check_cleanup_thresholds("delete.com", CLEANUP_DELETE_THRESHOLD)
|
||||
await monitor._check_cleanup_thresholds("delete.com", _CLEANUP_DELETE_THRESHOLD)
|
||||
assert 'domain="delete.com"' not in generate_metrics_output(metrics)
|
||||
|
||||
async def test_update_stream_does_not_recreate_metrics_after_cleanup(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Do not recreate per-domain metrics after update_stream deletes a stream."""
|
||||
owncast = _StubOwncastClient(stream_state=None)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
await _seed_stream(stream_repo, subscription_repo, domain="delete.com")
|
||||
metrics.set_stream_status("delete.com", StreamStatus.OFFLINE)
|
||||
metrics.set_check_failures("delete.com", _CLEANUP_DELETE_THRESHOLD - 1)
|
||||
|
||||
for _ in range(_CLEANUP_DELETE_THRESHOLD - 1):
|
||||
await stream_repo.increment_failure_counter("delete.com")
|
||||
|
||||
await monitor.update_stream("delete.com")
|
||||
|
||||
assert await stream_repo.get_by_domain("delete.com") is None
|
||||
assert await subscription_repo.get_subscribed_rooms("delete.com") == []
|
||||
assert 'domain="delete.com"' not in generate_metrics_output(metrics)
|
||||
|
||||
async def test_records_subscription_counts(
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
# 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 subscription manager business logic."""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.subscription_manager import SubscriptionManager, _domainify
|
||||
from owncastsentry.types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
StreamState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
class TestDomainify:
|
||||
"""Domain extraction and sanitization from user input."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_url", "expected"),
|
||||
[
|
||||
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||
pytest.param(" example.com ", "example.com", id="surrounding-whitespace"),
|
||||
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||
pytest.param(
|
||||
"https://example.com/path/to/page",
|
||||
"example.com",
|
||||
id="url-with-path",
|
||||
),
|
||||
pytest.param(
|
||||
"user@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="email-style",
|
||||
),
|
||||
pytest.param(
|
||||
"matrix@notify@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="last-at-sign-wins",
|
||||
),
|
||||
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||
pytest.param(
|
||||
"sub.domain.example.com",
|
||||
"sub.domain.example.com",
|
||||
id="subdomain",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||
"""Extract and sanitize the domain from various input formats."""
|
||||
assert _domainify(input_url) == expected
|
||||
|
||||
|
||||
class _StubOwncastClient:
|
||||
"""Owncast client stub for validation-only manager tests."""
|
||||
|
||||
def __init__(self, *, valid: bool = True) -> None:
|
||||
"""Initialize the stub with a fixed validation result."""
|
||||
self.valid = valid
|
||||
self.validated_domains: list[str] = []
|
||||
|
||||
async def validate_instance(self, domain: str) -> bool:
|
||||
"""Record the domain and return the configured validation result."""
|
||||
self.validated_domains.append(domain)
|
||||
return self.valid
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def owncast_client() -> _StubOwncastClient:
|
||||
"""Return a validation-only Owncast client stub."""
|
||||
return _StubOwncastClient()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> SubscriptionManager:
|
||||
"""SubscriptionManager built directly for unit tests."""
|
||||
return SubscriptionManager(
|
||||
owncast_client=owncast_client, # type: ignore[arg-type]
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
|
||||
class TestManagerSubscribe:
|
||||
"""SubscriptionManager subscribe workflow."""
|
||||
|
||||
async def test_first_subscription_validates_and_creates_stream(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""First subscription validates the instance and creates stream state."""
|
||||
domain = await manager.subscribe(
|
||||
"!room:example.com", "https://Stream.Example/foo"
|
||||
)
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
assert await stream_repo.exists("stream.example") is True
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||
"!room:example.com"
|
||||
]
|
||||
|
||||
async def test_invalid_first_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Invalid first-time Owncast validation raises a domain error."""
|
||||
owncast_client.valid = False
|
||||
|
||||
with pytest.raises(InvalidOwncastInstanceError) as exc_info:
|
||||
await manager.subscribe("!room:example.com", "bad.example")
|
||||
|
||||
assert exc_info.value.domain == "bad.example"
|
||||
assert owncast_client.validated_domains == ["bad.example"]
|
||||
assert await stream_repo.exists("bad.example") is False
|
||||
assert await subscription_repo.get_subscribed_rooms("bad.example") == []
|
||||
|
||||
async def test_duplicate_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Duplicate room subscription raises AlreadySubscribedError."""
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
with pytest.raises(AlreadySubscribedError) as exc_info:
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
assert exc_info.value.domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||
"!room:example.com"
|
||||
]
|
||||
|
||||
async def test_existing_stream_new_room_skips_validation(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
owncast_client: _StubOwncastClient,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Known domains skip remote validation for additional room subscriptions."""
|
||||
await manager.subscribe("!room1:example.com", "stream.example")
|
||||
owncast_client.valid = False
|
||||
|
||||
domain = await manager.subscribe("!room2:example.com", "stream.example")
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert owncast_client.validated_domains == ["stream.example"]
|
||||
rooms = await subscription_repo.get_subscribed_rooms("stream.example")
|
||||
assert sorted(rooms) == [
|
||||
"!room1:example.com",
|
||||
"!room2:example.com",
|
||||
]
|
||||
|
||||
|
||||
class TestManagerUnsubscribe:
|
||||
"""SubscriptionManager unsubscribe workflow."""
|
||||
|
||||
async def test_removes_existing_subscription(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Existing room subscription is removed and its domain is returned."""
|
||||
await manager.subscribe("!room:example.com", "stream.example")
|
||||
|
||||
domain = await manager.unsubscribe("!room:example.com", "stream.example")
|
||||
|
||||
assert domain == "stream.example"
|
||||
assert await subscription_repo.get_subscribed_rooms("stream.example") == []
|
||||
|
||||
async def test_missing_subscription_raises(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
) -> None:
|
||||
"""Removing a non-existent subscription raises NotSubscribedError."""
|
||||
with pytest.raises(NotSubscribedError) as exc_info:
|
||||
await manager.unsubscribe("!room:example.com", "missing.example")
|
||||
|
||||
assert exc_info.value.domain == "missing.example"
|
||||
|
||||
|
||||
class TestManagerListings:
|
||||
"""SubscriptionManager room listing behavior."""
|
||||
|
||||
async def test_list_room_subscriptions_returns_sorted_stream_states(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Return sorted room subscriptions and skip missing stream rows."""
|
||||
await stream_repo.create("beta.example")
|
||||
await stream_repo.update(StreamState(domain="beta.example", name="Beta"))
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update(StreamState(domain="alpha.example", name="Alpha"))
|
||||
await subscription_repo.add("beta.example", "!room:example.com")
|
||||
await subscription_repo.add("missing.example", "!room:example.com")
|
||||
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||
|
||||
subscriptions = await manager.list_room_subscriptions("!room:example.com")
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"alpha.example",
|
||||
"beta.example",
|
||||
]
|
||||
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||
"Alpha",
|
||||
"Beta",
|
||||
]
|
||||
|
||||
async def test_list_live_room_subscriptions_filters_online_streams(
|
||||
self,
|
||||
manager: SubscriptionManager,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Live listing includes only subscriptions with ONLINE stream status."""
|
||||
await stream_repo.create("offline.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="offline.example",
|
||||
name="Offline",
|
||||
last_disconnect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="online.example",
|
||||
name="Online",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="unknown.example",
|
||||
name="Unknown",
|
||||
last_connect_time="2026-01-01T00:00:00Z",
|
||||
)
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
await subscription_repo.add("offline.example", "!room:example.com")
|
||||
await subscription_repo.add("online.example", "!room:example.com")
|
||||
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||
|
||||
subscriptions = await manager.list_live_room_subscriptions("!room:example.com")
|
||||
|
||||
assert [subscription.domain for subscription in subscriptions] == [
|
||||
"online.example"
|
||||
]
|
||||
@@ -0,0 +1,352 @@
|
||||
# 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 data models."""
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.types import (
|
||||
_MAX_INSTANCE_TITLE_LENGTH,
|
||||
_MAX_STREAM_TITLE_LENGTH,
|
||||
_MAX_TAG_LENGTH,
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
InvalidApiResponseError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
RoomSubscription,
|
||||
StreamConfig,
|
||||
StreamState,
|
||||
StreamStatus,
|
||||
SubscriptionError,
|
||||
UpdateResult,
|
||||
_truncate,
|
||||
)
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
"""Text truncation to a maximum length."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "max_length", "expected"),
|
||||
[
|
||||
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||
pytest.param("", 5, "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||
"""Truncate text that exceeds the maximum length."""
|
||||
assert _truncate(text, max_length) == expected
|
||||
|
||||
|
||||
class TestStreamStateStatus:
|
||||
"""Stream status derivation from state fields."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("failure_counter", "last_connect_time", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
None,
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-offline-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.UNKNOWN,
|
||||
id="above-threshold-online-returns-unknown",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="zero-failures-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
0,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="zero-failures-no-connect-time-returns-offline",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
"2026-01-01T00:00:00Z",
|
||||
StreamStatus.ONLINE,
|
||||
id="at-threshold-with-connect-time-returns-online",
|
||||
),
|
||||
pytest.param(
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
None,
|
||||
StreamStatus.OFFLINE,
|
||||
id="at-threshold-no-connect-time-returns-offline",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_status(
|
||||
self,
|
||||
failure_counter: int,
|
||||
last_connect_time: str | None,
|
||||
expected: StreamStatus,
|
||||
) -> None:
|
||||
"""Return the correct status based on failure counter and connect time."""
|
||||
state = StreamState(
|
||||
domain="example.com",
|
||||
failure_counter=failure_counter,
|
||||
last_connect_time=last_connect_time,
|
||||
)
|
||||
assert state.status is expected
|
||||
|
||||
|
||||
class TestStreamStateFromApiResponse:
|
||||
"""StreamState construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate all fields from a complete API response."""
|
||||
response = {
|
||||
"streamTitle": "My Stream",
|
||||
"lastConnectTime": "2026-01-01T00:00:00Z",
|
||||
"lastDisconnectTime": "2025-12-31T23:00:00Z",
|
||||
"online": True,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert state.domain == "example.com"
|
||||
assert state.title == "My Stream"
|
||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
||||
assert state.name is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
def test_missing_required_field_raises(self) -> None:
|
||||
"""Reject API responses without required stream state fields."""
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamState.from_api_response({}, "bare.example.com")
|
||||
|
||||
def test_nullable_timestamp_fields(self) -> None:
|
||||
"""Accept null values for Owncast timestamp fields."""
|
||||
response = {
|
||||
"streamTitle": "Offline Stream",
|
||||
"lastConnectTime": None,
|
||||
"lastDisconnectTime": None,
|
||||
"online": False,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert state.last_connect_time is None
|
||||
assert state.last_disconnect_time is None
|
||||
|
||||
def test_title_truncation(self) -> None:
|
||||
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
|
||||
long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50)
|
||||
response = {
|
||||
"streamTitle": long_title,
|
||||
"lastConnectTime": None,
|
||||
"lastDisconnectTime": None,
|
||||
"online": True,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com")
|
||||
assert len(state.title) == _MAX_STREAM_TITLE_LENGTH
|
||||
assert state.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
pytest.param("streamTitle", 123, id="title-not-string"),
|
||||
pytest.param("lastConnectTime", [], id="connect-time-not-string-or-null"),
|
||||
pytest.param(
|
||||
"lastDisconnectTime",
|
||||
{},
|
||||
id="disconnect-time-not-string-or-null",
|
||||
),
|
||||
pytest.param("online", "true", id="online-not-bool"),
|
||||
],
|
||||
)
|
||||
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||
"""Reject stream state responses with malformed field types."""
|
||||
response: dict[str, object] = {
|
||||
"streamTitle": "My Stream",
|
||||
"lastConnectTime": None,
|
||||
"lastDisconnectTime": None,
|
||||
"online": True,
|
||||
}
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamState.from_api_response(response, "example.com")
|
||||
|
||||
|
||||
class TestStreamStateFromDbRow:
|
||||
"""StreamState construction from a database row dictionary."""
|
||||
|
||||
def test_typical_row(self) -> None:
|
||||
"""Populate all fields from a complete database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": "Test Instance",
|
||||
"title": "Live Now",
|
||||
"last_connect_time": "2026-01-01T00:00:00Z",
|
||||
"last_disconnect_time": "2025-12-31T23:00:00Z",
|
||||
"failure_counter": 3,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name == "Test Instance"
|
||||
assert state.title == "Live Now"
|
||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
||||
assert state.failure_counter == 3
|
||||
|
||||
def test_row_with_none_optional_fields(self) -> None:
|
||||
"""Accept None for optional fields in a database row."""
|
||||
row = {
|
||||
"domain": "example.com",
|
||||
"name": None,
|
||||
"title": None,
|
||||
"last_connect_time": None,
|
||||
"last_disconnect_time": None,
|
||||
"failure_counter": 0,
|
||||
}
|
||||
state = StreamState.from_db_row(row)
|
||||
assert state.domain == "example.com"
|
||||
assert state.name is None
|
||||
assert state.title is None
|
||||
assert state.last_connect_time is None
|
||||
assert state.last_disconnect_time is None
|
||||
assert state.failure_counter == 0
|
||||
|
||||
|
||||
class TestStreamConfigFromApiResponse:
|
||||
"""StreamConfig construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate name and tags from a complete API response."""
|
||||
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert config.name == "My Instance"
|
||||
assert config.tags == ("gaming", "music")
|
||||
|
||||
def test_missing_keys_defaults(self) -> None:
|
||||
"""Use defaults when name and tags keys are missing."""
|
||||
config = StreamConfig.from_api_response({})
|
||||
assert config.name == ""
|
||||
assert config.tags == ()
|
||||
|
||||
def test_name_truncation(self) -> None:
|
||||
"""Truncate the instance name to _MAX_INSTANCE_TITLE_LENGTH."""
|
||||
long_name = "B" * (_MAX_INSTANCE_TITLE_LENGTH + 50)
|
||||
response = {"name": long_name, "tags": []}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert len(config.name) == _MAX_INSTANCE_TITLE_LENGTH
|
||||
assert config.name == "B" * _MAX_INSTANCE_TITLE_LENGTH
|
||||
|
||||
def test_tag_truncation(self) -> None:
|
||||
"""Truncate each tag to _MAX_TAG_LENGTH."""
|
||||
long_tag = "C" * (_MAX_TAG_LENGTH + 10)
|
||||
response = {"name": "", "tags": [long_tag, "short"]}
|
||||
config = StreamConfig.from_api_response(response)
|
||||
assert len(config.tags[0]) == _MAX_TAG_LENGTH
|
||||
assert config.tags[0] == "C" * _MAX_TAG_LENGTH
|
||||
assert config.tags[1] == "short"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
pytest.param("name", None, id="name-not-string"),
|
||||
pytest.param("tags", "gaming", id="tags-not-list"),
|
||||
pytest.param("tags", ["gaming", 123], id="tag-not-string"),
|
||||
],
|
||||
)
|
||||
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||
"""Reject stream config responses with malformed field types."""
|
||||
response: dict[str, object] = {"name": "My Instance", "tags": ["gaming"]}
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamConfig.from_api_response(response)
|
||||
|
||||
|
||||
class TestValueTypeImmutability:
|
||||
"""Dataclass value containers are immutable snapshots."""
|
||||
|
||||
def test_stream_state_is_immutable(self) -> None:
|
||||
"""StreamState cannot be mutated in place."""
|
||||
state = StreamState(domain="stream.example")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.title = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_config_is_immutable(self) -> None:
|
||||
"""StreamConfig cannot be mutated in place."""
|
||||
config = StreamConfig(name="Stream")
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
config.name = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_config_tags_are_immutable(self) -> None:
|
||||
"""StreamConfig tags are stored in an immutable tuple."""
|
||||
config = StreamConfig(name="Stream", tags=("gaming",))
|
||||
assert config.tags == ("gaming",)
|
||||
|
||||
def test_update_result_is_immutable(self) -> None:
|
||||
"""UpdateResult cannot be mutated in place."""
|
||||
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
result.failed_checks = 1 # type: ignore[misc]
|
||||
|
||||
|
||||
class TestSubscriptionTypes:
|
||||
"""Subscription display containers and domain error hierarchy."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_cls",
|
||||
[
|
||||
pytest.param(InvalidOwncastInstanceError, id="invalid-instance"),
|
||||
pytest.param(AlreadySubscribedError, id="already-subscribed"),
|
||||
pytest.param(NotSubscribedError, id="not-subscribed"),
|
||||
],
|
||||
)
|
||||
def test_errors_subclass_subscription_error(
|
||||
self, error_cls: type[Exception]
|
||||
) -> None:
|
||||
"""Every subscription domain error subclasses SubscriptionError."""
|
||||
assert issubclass(error_cls, SubscriptionError)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
pytest.param(
|
||||
InvalidOwncastInstanceError("bad.example"),
|
||||
id="invalid-instance",
|
||||
),
|
||||
pytest.param(
|
||||
AlreadySubscribedError("dupe.example"),
|
||||
id="already-subscribed",
|
||||
),
|
||||
pytest.param(
|
||||
NotSubscribedError("missing.example"),
|
||||
id="not-subscribed",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_errors_store_domain(self, error: SubscriptionError) -> None:
|
||||
"""Subscription domain errors expose the stream domain that failed."""
|
||||
assert error.domain in str(error)
|
||||
|
||||
def test_room_subscription_is_immutable(self) -> None:
|
||||
"""RoomSubscription is an immutable stream display snapshot."""
|
||||
state = StreamState(domain="stream.example")
|
||||
subscription = RoomSubscription(domain="stream.example", stream_state=state)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
subscription.domain = "other.example" # type: ignore[misc]
|
||||
@@ -1,197 +0,0 @@
|
||||
# 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 utility functions and constants."""
|
||||
|
||||
import pytest
|
||||
|
||||
from owncastsentry.utils import (
|
||||
domainify,
|
||||
escape_markdown,
|
||||
sanitize_for_markdown,
|
||||
sanitize_for_plain_text,
|
||||
should_query_stream,
|
||||
truncate,
|
||||
user_agent,
|
||||
)
|
||||
|
||||
|
||||
class TestUserAgent:
|
||||
"""User-Agent header construction."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("version", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
"1.2.3",
|
||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="semver",
|
||||
),
|
||||
pytest.param(
|
||||
"0.0.0",
|
||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="zeroed",
|
||||
),
|
||||
pytest.param(
|
||||
"1.1.1.dev10+gf0146d061.d20260313",
|
||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||
id="dev-version",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_user_agent(self, version: str, expected: str) -> None:
|
||||
"""Build a correctly formatted User-Agent header."""
|
||||
assert user_agent(version) == expected
|
||||
|
||||
|
||||
class TestShouldQueryStream:
|
||||
"""Progressive backoff logic for stream polling."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("counter", "expected"),
|
||||
[
|
||||
pytest.param(0, True, id="counter-0-always-query"),
|
||||
pytest.param(1, True, id="counter-1-always-query"),
|
||||
pytest.param(4, True, id="counter-4-always-query"),
|
||||
pytest.param(5, False, id="counter-5-skip-odd"),
|
||||
pytest.param(6, True, id="counter-6-query-even"),
|
||||
pytest.param(9, False, id="counter-9-skip-odd"),
|
||||
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
|
||||
pytest.param(12, True, id="counter-12-query-mod-3"),
|
||||
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
|
||||
pytest.param(15, True, id="counter-15-query-mod-5"),
|
||||
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
|
||||
pytest.param(20, True, id="counter-20-query-mod-5"),
|
||||
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
|
||||
pytest.param(30, True, id="counter-30-query-mod-15"),
|
||||
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
|
||||
pytest.param(45, True, id="counter-45-query-mod-15"),
|
||||
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
|
||||
pytest.param(105, True, id="counter-105-query-mod-15"),
|
||||
],
|
||||
)
|
||||
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
|
||||
"""Return the expected query decision for each backoff tier."""
|
||||
assert should_query_stream(counter) == expected
|
||||
|
||||
|
||||
class TestDomainify:
|
||||
"""Domain extraction and sanitization from user input."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input_url", "expected"),
|
||||
[
|
||||
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||
pytest.param(
|
||||
"https://example.com/path/to/page",
|
||||
"example.com",
|
||||
id="url-with-path",
|
||||
),
|
||||
pytest.param(
|
||||
"user@stream.logal.dev",
|
||||
"stream.logal.dev",
|
||||
id="email-style",
|
||||
),
|
||||
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||
pytest.param(
|
||||
"sub.domain.example.com",
|
||||
"sub.domain.example.com",
|
||||
id="subdomain",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||
"""Extract and sanitize the domain from various input formats."""
|
||||
assert domainify(input_url) == expected
|
||||
|
||||
|
||||
class TestTruncate:
|
||||
"""Text truncation to a maximum length."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "max_length", "expected"),
|
||||
[
|
||||
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||
pytest.param("", 5, "", id="empty-string"),
|
||||
],
|
||||
)
|
||||
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||
"""Truncate text that exceeds the maximum length."""
|
||||
assert truncate(text, max_length) == expected
|
||||
|
||||
|
||||
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 the given Markdown special character."""
|
||||
assert escape_markdown(input_text) == expected
|
||||
|
||||
|
||||
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 TestSanitizeForMarkdown:
|
||||
"""Markdown sanitization combining newline removal and escaping."""
|
||||
|
||||
def test_removes_newlines_and_escapes(self) -> None:
|
||||
"""Remove 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("") == ""
|
||||
Reference in New Issue
Block a user