Added comprehensive test suite with CI workflow.
Audit / Dependencies (push) Successful in 8s
CD / Build (push) Successful in 8s
CI / Formatting (push) Successful in 5s
CI / Linting (push) Successful in 5s
CI / Tests (push) Successful in 11s
CI / Type Checking (push) Successful in 9s
CI / Spelling (push) Successful in 6s

This commit is contained in:
2026-03-14 12:15:33 -04:00
parent 58993ff5ae
commit 99b257b90a
14 changed files with 2989 additions and 7 deletions
+839
View File
@@ -0,0 +1,839 @@
# Copyright 2026 Logan Fick
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Tests for the stream monitor."""
from __future__ import annotations
import logging
import time
from typing import TYPE_CHECKING
from owncastsentry.models import StreamConfig, StreamState
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 tests.conftest import _StubMatrixClient, _StubOwncastClient
if TYPE_CHECKING:
from owncastsentry.database import StreamRepository, SubscriptionRepository
def _make_monitor(
*,
owncast_client: _StubOwncastClient,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
client: _StubMatrixClient,
) -> tuple[StreamMonitor, NotificationService]:
"""Build a StreamMonitor with stubs and a real NotificationService."""
logger = logging.getLogger("test")
notification_service = NotificationService(
client=client,
subscription_repo=subscription_repo,
logger=logger,
)
monitor = StreamMonitor(
owncast_client=owncast_client,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
notification_service=notification_service,
logger=logger,
)
return monitor, notification_service
async def _seed_stream(
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
*,
domain: str = "example.com",
room_id: str = "!room:matrix.org",
name: str | None = "Test Stream",
title: str | None = "Test Title",
last_connect_time: str | None = None,
last_disconnect_time: str | None = None,
) -> None:
"""Insert a stream and subscription into the database."""
await stream_repo.create(domain)
state = StreamState(
domain=domain,
name=name,
title=title,
last_connect_time=last_connect_time,
last_disconnect_time=last_disconnect_time,
)
await stream_repo.update(state)
await subscription_repo.add(domain, room_id)
class TestUpdateAllStreams:
"""Parallel stream update orchestration."""
async def test_returns_correct_counts(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return an UpdateResult with correct success and failure counts."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="ok.com", last_connect_time="2026-01-01T00:00:00Z"
),
stream_config=StreamConfig(name="OK"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(stream_repo, subscription_repo, domain="ok.com")
result = await monitor.update_all_streams(["ok.com"])
assert result.total_streams == 1
assert result.successful_checks == 1
assert result.failed_checks == 0
assert owncast.queried_domains == ["ok.com"]
class TestUpdateStreamBackoff:
"""Backoff logic that skips queries for failing streams."""
async def test_skips_query_on_backoff(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip the HTTP query and increment the counter during backoff."""
owncast = _StubOwncastClient()
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(stream_repo, subscription_repo, domain="fail.com")
# Set failure counter to 5 (should skip odd cycles)
for _ in range(5):
await stream_repo.increment_failure_counter("fail.com")
result = await monitor.update_stream("fail.com")
assert result is True
assert owncast.state_call_count == 0
# Counter should have been incremented to 6
state = await stream_repo.get_by_domain("fail.com")
assert state is not None
assert state.failure_counter == 6
class TestUpdateStreamFirstUpdate:
"""First state update of a newly subscribed stream."""
async def test_no_notification_on_first_live(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip notifications when a stream is already live on first update."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="new.com",
last_connect_time="2026-01-01T00:00:00Z",
last_disconnect_time="2025-12-31T00:00:00Z",
),
stream_config=StreamConfig(name="New Stream"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed with no connect/disconnect times (brand new)
await stream_repo.create("new.com")
await subscription_repo.add("new.com", "!room:matrix.org")
result = await monitor.update_stream("new.com")
assert result is True
assert len(client.sent_messages) == 0
async def test_no_notification_on_first_offline(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip notifications when a stream is offline on first update."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="new.com",
last_disconnect_time="2025-12-31T00:00:00Z",
),
stream_config=StreamConfig(name="New Stream"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await stream_repo.create("new.com")
await subscription_repo.add("new.com", "!room:matrix.org")
result = await monitor.update_stream("new.com")
assert result is True
assert len(client.sent_messages) == 0
class TestUpdateStreamGoesLive:
"""Stream transitioning from offline to online."""
async def test_sends_go_live_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send go-live with stream name and tags."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="live.com",
title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z",
last_disconnect_time="2026-01-01T10:00:00Z",
),
stream_config=StreamConfig(name="Live Stream", tags=["gaming"]),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as offline (has disconnect but no connect)
await _seed_stream(
stream_repo,
subscription_repo,
domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z",
)
# Set offline timer to long ago so it's not a brief outage
monitor.offline_timer_cache["live.com"] = 0
result = await monitor.update_stream("live.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"🎥 Live Stream is now live!\n"
"Stream Title: Now Streaming\n"
"\n"
"To tune in, visit: https://live.com/\n"
"\n"
"#gaming"
)
async def test_falls_back_when_config_fetch_fails(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Use domain as name in go-live when config fetch fails."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="live.com",
title="Now Streaming",
last_connect_time="2026-01-01T12:00:00Z",
last_disconnect_time="2026-01-01T10:00:00Z",
),
stream_config=None,
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(
stream_repo,
subscription_repo,
domain="live.com",
last_disconnect_time="2026-01-01T10:00:00Z",
)
monitor.offline_timer_cache["live.com"] = 0
result = await monitor.update_stream("live.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"🎥 live.com is now live!\n"
"Stream Title: Now Streaming\n"
"\n"
"To tune in, visit: https://live.com/"
)
class TestUpdateStreamBriefOffline:
"""Stream that was briefly offline and comes back."""
async def test_no_notification_same_title(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip notification for a brief outage with no title change."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="brief.com",
title="Same Title",
last_connect_time="2026-01-01T12:00:00Z",
),
stream_config=StreamConfig(name="Brief Stream"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as offline with same title
await _seed_stream(
stream_repo,
subscription_repo,
domain="brief.com",
title="Same Title",
last_disconnect_time="2026-01-01T11:55:00Z",
)
# Recently offline (within cooldown)
monitor.offline_timer_cache["brief.com"] = time.time() - 60
result = await monitor.update_stream("brief.com")
assert result is True
assert len(client.sent_messages) == 0
async def test_title_change_notification(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send a title-change notification for a brief outage with a new title."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="brief.com",
title="New Title",
last_connect_time="2026-01-01T12:00:00Z",
),
stream_config=StreamConfig(name="Brief Stream"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as offline with old title
await _seed_stream(
stream_repo,
subscription_repo,
domain="brief.com",
title="Old Title",
last_disconnect_time="2026-01-01T11:55:00Z",
)
# Recently offline (within cooldown)
monitor.offline_timer_cache["brief.com"] = time.time() - 60
result = await monitor.update_stream("brief.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"📝 Brief Stream has changed its stream title!\n"
"Stream Title: New Title\n"
"\n"
"To tune in, visit: https://brief.com/"
)
class TestUpdateStreamTitleChange:
"""Mid-session title change while stream stays online."""
async def test_falls_back_when_config_fetch_fails(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Use domain as name in title-change when config fails."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
),
stream_config=None,
)
client = _StubMatrixClient()
monitor, notification_service = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(
stream_repo,
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
)
monitor.offline_timer_cache["title.com"] = 0
# Subtract an extra second to ensure the cooldown has fully elapsed
notification_service.notification_timers_cache["title.com"] = (
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1
)
result = await monitor.update_stream("title.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"📝 title.com has changed its stream title!\n"
"Stream Title: Updated Title\n"
"\n"
"To tune in, visit: https://title.com/"
)
async def test_sends_title_change(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send a title-change notification when the title changes mid-stream."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
),
stream_config=StreamConfig(name="Title Stream"),
)
client = _StubMatrixClient()
monitor, notification_service = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as online with different title
await _seed_stream(
stream_repo,
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
)
# 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
# Subtract an extra second to ensure the cooldown has fully elapsed
notification_service.notification_timers_cache["title.com"] = (
time.time() - SECONDS_BETWEEN_NOTIFICATIONS - 1
)
result = await monitor.update_stream("title.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"📝 Title Stream has changed its stream title!\n"
"Stream Title: Updated Title\n"
"\n"
"To tune in, visit: https://title.com/"
)
async def test_sends_go_live_when_last_notification_before_offline(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send go-live instead of title-change after an offline gap."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="title.com",
title="Updated Title",
last_connect_time="2026-01-01T12:00:00Z",
),
stream_config=StreamConfig(name="Title Stream"),
)
client = _StubMatrixClient()
monitor, notification_service = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(
stream_repo,
subscription_repo,
domain="title.com",
title="Original Title",
last_connect_time="2026-01-01T12:00:00Z",
)
# Offline timer is MORE recent than last notification,
# and both are old enough to pass rate limiting
now = time.time()
monitor.offline_timer_cache["title.com"] = (
now - SECONDS_BETWEEN_NOTIFICATIONS - 100
)
notification_service.notification_timers_cache["title.com"] = (
now - SECONDS_BETWEEN_NOTIFICATIONS - 200
)
result = await monitor.update_stream("title.com")
assert result is True
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"🎥 Title Stream is now live!\n"
"Stream Title: Updated Title\n"
"\n"
"To tune in, visit: https://title.com/"
)
class TestUpdateStreamGoesOffline:
"""Stream transitioning from online to offline."""
async def test_records_offline_time(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Record the offline time in the cache when a stream goes offline."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="offline.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
),
stream_config=StreamConfig(name="Offline Stream"),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as online
await _seed_stream(
stream_repo,
subscription_repo,
domain="offline.com",
last_connect_time="2026-01-01T10:00:00Z",
)
monitor.offline_timer_cache["offline.com"] = 0
before = time.time()
result = await monitor.update_stream("offline.com")
after = time.time()
assert result is True
assert before <= monitor.offline_timer_cache["offline.com"] <= after
assert len(client.sent_messages) == 0
class TestUpdateStreamConnectionFailure:
"""Connection failure handling."""
async def test_returns_false_and_increments_counter(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return False and increment the failure counter on connection failure."""
owncast = _StubOwncastClient(stream_state=None)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(
stream_repo,
subscription_repo,
domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z",
)
result = await monitor.update_stream("fail.com")
assert result is False
state = await stream_repo.get_by_domain("fail.com")
assert state is not None
assert state.failure_counter == 1
class TestCheckCleanupThresholds:
"""Auto-cleanup warning and deletion thresholds."""
async def test_sends_warning_at_threshold(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Send a cleanup warning when the failure counter hits the 83-day threshold."""
owncast = _StubOwncastClient()
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(stream_repo, subscription_repo, domain="warn.com")
await monitor._check_cleanup_thresholds("warn.com", CLEANUP_WARNING_THRESHOLD)
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"⚠️ Warning: Subscription Cleanup Scheduled\n"
"\n"
"The Owncast instance at warn.com has been "
"unreachable for 83 days. If it remains "
"unreachable for 7 more days "
"(90 days total), this subscription "
"will be automatically removed."
)
async def test_deletes_at_threshold(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Delete all subscriptions and the stream record at the 90-day threshold."""
owncast = _StubOwncastClient()
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(stream_repo, subscription_repo, domain="delete.com")
await monitor._check_cleanup_thresholds("delete.com", CLEANUP_DELETE_THRESHOLD)
# Deletion notification sent
assert len(client.sent_messages) == 1
assert client.sent_messages[0].content.body == (
"🗑️ Subscription Automatically Removed\n"
"\n"
"The Owncast instance at delete.com has been "
"unreachable for 90 days and has been "
"automatically removed from subscriptions in this "
"room.\n"
"\n"
"If the instance comes online again and you want to "
"resubscribe, run `!subscribe delete.com`."
)
# Stream and subscriptions removed from DB
assert await stream_repo.get_by_domain("delete.com") is None
rooms = await subscription_repo.get_subscribed_rooms("delete.com")
assert rooms == []
async def test_no_action_below_thresholds(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Take no action when the counter is below both thresholds."""
owncast = _StubOwncastClient()
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(stream_repo, subscription_repo, domain="ok.com")
await monitor._check_cleanup_thresholds("ok.com", 100)
assert len(client.sent_messages) == 0
assert await stream_repo.get_by_domain("ok.com") is not None
class TestUpdateStreamNoStateChange:
"""Stream that stays offline across updates with no state change."""
async def test_skips_database_write(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Skip the database write when stream state has not changed."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="stable.com",
title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z",
),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# Seed as offline with same disconnect time and title
await _seed_stream(
stream_repo,
subscription_repo,
domain="stable.com",
title="Same Title",
last_disconnect_time="2026-01-01T12:00:00Z",
)
result = await monitor.update_stream("stable.com")
assert result is True
assert len(client.sent_messages) == 0
# Config should not have been fetched since no DB update was needed
assert owncast.config_call_count == 0
class TestUpdateStreamFailureCounterReset:
"""Failure counter reset on successful fetch."""
async def test_resets_counter_on_success(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Reset the failure counter to zero after a successful fetch."""
owncast = _StubOwncastClient(
stream_state=StreamState(
domain="recover.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
),
)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
await _seed_stream(
stream_repo,
subscription_repo,
domain="recover.com",
title="Title",
last_disconnect_time="2026-01-01T12:00:00Z",
)
# Simulate prior failures (counter=4 still passes backoff)
for _ in range(4):
await stream_repo.increment_failure_counter("recover.com")
state = await stream_repo.get_by_domain("recover.com")
assert state is not None
assert state.failure_counter == 4
result = await monitor.update_stream("recover.com")
assert result is True
state = await stream_repo.get_by_domain("recover.com")
assert state is not None
assert state.failure_counter == 0
class TestUpdateAllStreamsMixed:
"""Mixed success/failure results from parallel updates."""
async def test_counts_mixed_results(
self,
stream_repo: StreamRepository,
subscription_repo: SubscriptionRepository,
) -> None:
"""Return correct counts with one failure and one backoff skip."""
# Stub returns None, so all actual fetches fail
owncast = _StubOwncastClient(stream_state=None)
client = _StubMatrixClient()
monitor, _ = _make_monitor(
owncast_client=owncast,
stream_repo=stream_repo,
subscription_repo=subscription_repo,
client=client,
)
# "fail.com" will be fetched and fail (counter=0, no backoff)
await _seed_stream(
stream_repo,
subscription_repo,
domain="fail.com",
last_disconnect_time="2026-01-01T00:00:00Z",
)
# "skip.com" will be skipped via backoff (returns True)
await _seed_stream(
stream_repo,
subscription_repo,
domain="skip.com",
room_id="!room2:matrix.org",
last_disconnect_time="2026-01-01T00:00:00Z",
)
for _ in range(5):
await stream_repo.increment_failure_counter("skip.com")
result = await monitor.update_all_streams(["fail.com", "skip.com"])
assert result.total_streams == 2
assert result.successful_checks == 1
assert result.failed_checks == 1