Refactored stream observations and config refresh handling.
This commit is contained in:
+6
-6
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
|
||||
from mautrix.util.config import BaseProxyConfig
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.types import StreamConfig, StreamState
|
||||
from owncastsentry.types import StreamConfigObservation, StreamStateObservation
|
||||
|
||||
|
||||
def generate_metrics_output(metrics: MetricsService) -> str:
|
||||
@@ -212,19 +212,19 @@ class _StubMatrixClient:
|
||||
class _StubOwncastClient:
|
||||
"""Configurable stub for the Owncast HTTP client."""
|
||||
|
||||
stream_state: StreamState | None = None
|
||||
stream_config: StreamConfig | None = None
|
||||
stream_state: StreamStateObservation | None = None
|
||||
stream_config: StreamConfigObservation | None = None
|
||||
state_call_count: int = field(default=0, init=False)
|
||||
config_call_count: int = field(default=0, init=False)
|
||||
queried_domains: list[str] = field(default_factory=list, init=False)
|
||||
|
||||
async def get_stream_state(self, domain: str) -> StreamState | None:
|
||||
"""Return the configured stream state."""
|
||||
async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
|
||||
"""Return the configured stream observation."""
|
||||
self.state_call_count += 1
|
||||
self.queried_domains.append(domain)
|
||||
return self.stream_state
|
||||
|
||||
async def get_stream_config(self, _domain: str) -> StreamConfig | None:
|
||||
async def get_stream_config(self, _domain: str) -> StreamConfigObservation | None:
|
||||
"""Return the configured stream config."""
|
||||
self.config_call_count += 1
|
||||
return self.stream_config
|
||||
|
||||
+40
-58
@@ -31,7 +31,7 @@ from owncastsentry.commands import (
|
||||
_sanitize_for_markdown,
|
||||
)
|
||||
from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
|
||||
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
|
||||
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD
|
||||
from tests.conftest import VALID_STATUS_RESPONSE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -258,13 +258,11 @@ class TestSubscriptionsCommand:
|
||||
|
||||
# Update stream state to be online with a name
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
@@ -294,13 +292,11 @@ class TestSubscriptionsCommand:
|
||||
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",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
"stream.logal.dev",
|
||||
name="*Bold* [link](https://evil.example)\nName",
|
||||
title="`code` > quote #tag",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
@@ -332,11 +328,9 @@ class TestSubscriptionsCommand:
|
||||
|
||||
# Update stream state to be offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
@@ -426,20 +420,16 @@ class TestSubscriptionsCommand:
|
||||
|
||||
# Set alpha online, beta offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Streaming Live",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
"alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Streaming Live",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="beta.com",
|
||||
name="Beta Stream",
|
||||
status_since="2026-03-12T18:00:00+00:00",
|
||||
)
|
||||
"beta.com",
|
||||
name="Beta Stream",
|
||||
status_since="2026-03-12T18:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!subscriptions")
|
||||
@@ -491,11 +481,9 @@ class TestLiveCommand:
|
||||
|
||||
# Update stream state to offline
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
@@ -523,13 +511,11 @@ class TestLiveCommand:
|
||||
|
||||
# Update stream state to online
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
"stream.logal.dev",
|
||||
name="Test Stream",
|
||||
title="Playing Games",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
@@ -563,22 +549,18 @@ class TestLiveCommand:
|
||||
|
||||
# Set both streams online with different status timestamps
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Morning Show",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
"alpha.com",
|
||||
name="Alpha Stream",
|
||||
title="Morning Show",
|
||||
online=True,
|
||||
status_since="2026-03-13T10:00:00+00:00",
|
||||
)
|
||||
await maubot_plugin.stream_repo.update(
|
||||
StreamState(
|
||||
domain="beta.com",
|
||||
name="Beta Stream",
|
||||
title="Evening Vibes",
|
||||
online=True,
|
||||
status_since="2026-03-13T06:00:00+00:00",
|
||||
)
|
||||
"beta.com",
|
||||
name="Beta Stream",
|
||||
title="Evening Vibes",
|
||||
online=True,
|
||||
status_since="2026-03-13T06:00:00+00:00",
|
||||
)
|
||||
|
||||
await maubot_test_bot.send("!live")
|
||||
|
||||
@@ -148,13 +148,13 @@ class TestReadLimitedResponseBody:
|
||||
|
||||
|
||||
class TestGetStreamState:
|
||||
"""Stream state retrieval from the status API."""
|
||||
"""Stream observation retrieval from the status API."""
|
||||
|
||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||
async def test_returns_state_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamState with correct fields on a valid 200 response."""
|
||||
"""Return a StreamStateObservation with correct fields on a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/status",
|
||||
@@ -169,7 +169,7 @@ class TestGetStreamState:
|
||||
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
|
||||
)
|
||||
assert result.online is False
|
||||
assert result.status_since == "2026-03-13T12:00:00+00:00"
|
||||
assert result.observed_at == "2026-03-13T12:00:00+00:00"
|
||||
|
||||
async def test_returns_none_on_missing_field(
|
||||
self, owncast_client: OwncastClient
|
||||
@@ -272,7 +272,7 @@ class TestGetStreamConfig:
|
||||
async def test_returns_config_on_success(
|
||||
self, owncast_client: OwncastClient
|
||||
) -> None:
|
||||
"""Return a StreamConfig with correct fields on a valid 200 response."""
|
||||
"""Return a StreamConfigObservation with fields from a valid response."""
|
||||
with aioresponses() as mocked:
|
||||
mocked.get(
|
||||
"https://stream.logal.dev/api/config",
|
||||
|
||||
+77
-20
@@ -23,7 +23,6 @@ from owncastsentry.types import (
|
||||
UNKNOWN_STATUS_THRESHOLD,
|
||||
AlreadySubscribedError,
|
||||
NotSubscribedError,
|
||||
StreamState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -129,6 +128,70 @@ class TestStreamDelete:
|
||||
assert await stream_repo.get_by_domain("example.com") is None
|
||||
|
||||
|
||||
class TestStreamUpdate:
|
||||
"""Partial stream state updates."""
|
||||
|
||||
async def test_updates_only_supplied_fields(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Update supplied fields while leaving omitted fields unchanged."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
name="Original Name",
|
||||
title="Original Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com", title="Updated Title")
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.name == "Original Name"
|
||||
assert state.title == "Updated Title"
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
async def test_can_set_nullable_field_to_null(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Treat None as a supplied NULL value, not an omitted field."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
name="Original Name",
|
||||
title="Original Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com", title=None)
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.name == "Original Name"
|
||||
assert state.title is None
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
async def test_accepts_no_supplied_fields(
|
||||
self, stream_repo: StreamRepository
|
||||
) -> None:
|
||||
"""Leave the row unchanged when no partial fields are supplied."""
|
||||
await stream_repo.create("example.com")
|
||||
await stream_repo.update(
|
||||
"example.com",
|
||||
title="Original Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
await stream_repo.update("example.com")
|
||||
|
||||
state = await stream_repo.get_by_domain("example.com")
|
||||
assert state is not None
|
||||
assert state.title == "Original Title"
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
|
||||
class TestGetSubscribedStreamsForRoom:
|
||||
"""Subscribed stream lookup by room."""
|
||||
|
||||
@@ -186,9 +249,9 @@ class TestGetRoomSubscriptions:
|
||||
) -> 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.update("beta.example", name="Beta")
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update(StreamState(domain="alpha.example", name="Alpha"))
|
||||
await stream_repo.update("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")
|
||||
@@ -227,29 +290,23 @@ class TestGetLiveRoomSubscriptions:
|
||||
"""Return only streams whose derived status is ONLINE."""
|
||||
await stream_repo.create("offline.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="offline.example",
|
||||
name="Offline",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"offline.example",
|
||||
name="Offline",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
+553
-141
@@ -16,9 +16,11 @@
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
|
||||
from owncastsentry.metrics import MetricsService
|
||||
from owncastsentry.notification_service import (
|
||||
@@ -38,9 +40,18 @@ from owncastsentry.stream_monitor import (
|
||||
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
|
||||
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||
StreamMonitor,
|
||||
_classify_notification,
|
||||
_classify_transition,
|
||||
_NotificationKind,
|
||||
_should_query_stream,
|
||||
_StreamTransitionKind,
|
||||
)
|
||||
from owncastsentry.types import (
|
||||
StreamConfigObservation,
|
||||
StreamState,
|
||||
StreamStateObservation,
|
||||
StreamStatus,
|
||||
)
|
||||
from owncastsentry.types import StreamConfig, StreamState, StreamStatus
|
||||
from tests.conftest import (
|
||||
_StubMatrixClient,
|
||||
_StubOwncastClient,
|
||||
@@ -92,17 +103,28 @@ async def _seed_stream(
|
||||
) -> None:
|
||||
"""Insert a stream and subscription into the database."""
|
||||
await stream_repo.create(domain)
|
||||
state = StreamState(
|
||||
domain=domain,
|
||||
await stream_repo.update(
|
||||
domain,
|
||||
name=name,
|
||||
title=title,
|
||||
online=online,
|
||||
status_since=status_since,
|
||||
)
|
||||
await stream_repo.update(state)
|
||||
await subscription_repo.add(domain, room_id)
|
||||
|
||||
|
||||
def _stream_observation(
|
||||
domain: str,
|
||||
*,
|
||||
observed_at: str,
|
||||
title: str | None = None,
|
||||
online: bool = False,
|
||||
) -> StreamStateObservation:
|
||||
"""Create a stream observation at a deterministic time."""
|
||||
with time_machine.travel(datetime.fromisoformat(observed_at), tick=False):
|
||||
return StreamStateObservation(domain=domain, title=title, online=online)
|
||||
|
||||
|
||||
def _make_monitor_with_metrics(
|
||||
*,
|
||||
owncast_client: _StubOwncastClient,
|
||||
@@ -213,6 +235,254 @@ class TestShouldQueryStream:
|
||||
assert _should_query_stream(counter) == expected
|
||||
|
||||
|
||||
class TestClassifyTransition:
|
||||
"""Pure stream state transition classification."""
|
||||
|
||||
def test_first_observation(self) -> None:
|
||||
"""Classify a missing stored timestamp as the first observation."""
|
||||
old_state = StreamState(domain="example.com")
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Live",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.FIRST_OBSERVATION
|
||||
assert transition.status_since == observation.observed_at
|
||||
|
||||
def test_went_live(self) -> None:
|
||||
"""Classify an offline to online status change."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.WENT_LIVE
|
||||
assert transition.status_since == observation.observed_at
|
||||
|
||||
def test_went_offline(self) -> None:
|
||||
"""Classify an online to offline status change."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.WENT_OFFLINE
|
||||
assert transition.status_since == observation.observed_at
|
||||
|
||||
def test_online_title_changed(self) -> None:
|
||||
"""Classify an online title change without resetting status duration."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Old",
|
||||
online=True,
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="New",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.TITLE_CHANGED
|
||||
assert transition.status_since == old_state.status_since
|
||||
|
||||
def test_unchanged(self) -> None:
|
||||
"""Classify identical status and title as unchanged."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Same",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Same",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.STATUS_UNCHANGED
|
||||
assert transition.status_since == old_state.status_since
|
||||
|
||||
def test_offline_title_change_keeps_status_timestamp(self) -> None:
|
||||
"""Keep offline status duration when only the title changes."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Old",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="New",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
assert transition.kind is _StreamTransitionKind.STATUS_UNCHANGED
|
||||
assert transition.status_since == old_state.status_since
|
||||
|
||||
|
||||
class TestClassifyNotification:
|
||||
"""Live/title notification policy classification."""
|
||||
|
||||
def test_first_observation_suppresses_notification(self) -> None:
|
||||
"""Do not notify for the first observed stream state."""
|
||||
old_state = StreamState(domain="example.com")
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(transition, old_state, observation)
|
||||
|
||||
assert notification_kind is _NotificationKind.NONE
|
||||
|
||||
def test_normal_went_live_notifies_live(self) -> None:
|
||||
"""Notify live after a non-brief offline gap."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Title",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(
|
||||
transition,
|
||||
old_state,
|
||||
observation,
|
||||
offline_duration_seconds=_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN + 1,
|
||||
)
|
||||
|
||||
assert notification_kind is _NotificationKind.LIVE
|
||||
|
||||
def test_brief_outage_same_title_suppresses_notification(self) -> None:
|
||||
"""Do not notify when a stream briefly returns with the same title."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Same",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="Same",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(
|
||||
transition,
|
||||
old_state,
|
||||
observation,
|
||||
offline_duration_seconds=60,
|
||||
)
|
||||
|
||||
assert notification_kind is _NotificationKind.NONE
|
||||
|
||||
def test_brief_outage_title_change_notifies_title_change(self) -> None:
|
||||
"""Notify title change when a brief return changes title."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Old",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="New",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(
|
||||
transition,
|
||||
old_state,
|
||||
observation,
|
||||
offline_duration_seconds=60,
|
||||
)
|
||||
|
||||
assert notification_kind is _NotificationKind.TITLE_CHANGE
|
||||
|
||||
def test_mid_session_title_change_notifies_title_change(self) -> None:
|
||||
"""Notify title change for a normal online title change."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Old",
|
||||
online=True,
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="New",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(transition, old_state, observation)
|
||||
|
||||
assert notification_kind is _NotificationKind.TITLE_CHANGE
|
||||
|
||||
def test_went_offline_after_last_notification_notifies_live(self) -> None:
|
||||
"""Notify live when stream went offline after the last notification."""
|
||||
old_state = StreamState(
|
||||
domain="example.com",
|
||||
title="Old",
|
||||
online=True,
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
observation = _stream_observation(
|
||||
domain="example.com",
|
||||
title="New",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
transition = _classify_transition(old_state, observation)
|
||||
|
||||
notification_kind = _classify_notification(
|
||||
transition,
|
||||
old_state,
|
||||
observation,
|
||||
went_offline_after_last_notification=True,
|
||||
)
|
||||
|
||||
assert notification_kind is _NotificationKind.LIVE
|
||||
|
||||
|
||||
class TestUpdateAllStreams:
|
||||
"""Parallel stream update orchestration."""
|
||||
|
||||
@@ -223,12 +493,12 @@ class TestUpdateAllStreams:
|
||||
) -> None:
|
||||
"""Return an UpdateResult with correct success and failure counts."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="ok.com",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
observed_at="2026-01-01T00:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="OK"),
|
||||
stream_config=StreamConfigObservation(name="OK"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -240,9 +510,9 @@ class TestUpdateAllStreams:
|
||||
|
||||
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
|
||||
result = await monitor.update_all_streams(["ok.com", "missing.com"])
|
||||
assert result.total_streams == 2
|
||||
assert result.successful_checks == 2
|
||||
assert result.failed_checks == 0
|
||||
assert owncast.queried_domains == ["ok.com"]
|
||||
|
||||
@@ -274,6 +544,7 @@ class TestUpdateStreamBackoff:
|
||||
result = await monitor.update_stream("fail.com")
|
||||
assert result is True
|
||||
assert owncast.state_call_count == 0
|
||||
assert owncast.config_call_count == 0
|
||||
|
||||
# Counter should have been incremented to 6
|
||||
state = await stream_repo.get_by_domain("fail.com")
|
||||
@@ -291,12 +562,12 @@ class TestUpdateStreamFirstUpdate:
|
||||
) -> None:
|
||||
"""Skip notifications when a stream is already live on first update."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="new.com",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
observed_at="2026-01-01T12:01:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="New Stream"),
|
||||
stream_config=StreamConfigObservation(name="New Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -313,6 +584,45 @@ class TestUpdateStreamFirstUpdate:
|
||||
result = await monitor.update_stream("new.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
async def test_fetches_config_and_saves_name_on_first_update(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Fetch config on first update and persist the stream display name."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=_stream_observation(
|
||||
domain="new.com",
|
||||
title="First Title",
|
||||
online=True,
|
||||
observed_at="2026-01-01T12:01:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfigObservation(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
|
||||
assert owncast.config_call_count == 1
|
||||
state = await stream_repo.get_by_domain("new.com")
|
||||
assert state is not None
|
||||
assert state.name == "New Stream"
|
||||
assert state.title == "First Title"
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T12:01:00+00:00"
|
||||
|
||||
async def test_no_notification_on_first_offline(
|
||||
self,
|
||||
@@ -321,11 +631,11 @@ class TestUpdateStreamFirstUpdate:
|
||||
) -> None:
|
||||
"""Skip notifications when a stream is offline on first update."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="new.com",
|
||||
status_since="2025-12-31T00:00:00+00:00",
|
||||
observed_at="2025-12-31T12:01:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="New Stream"),
|
||||
stream_config=StreamConfigObservation(name="New Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -341,6 +651,7 @@ class TestUpdateStreamFirstUpdate:
|
||||
result = await monitor.update_stream("new.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
|
||||
class TestUpdateStreamGoesLive:
|
||||
@@ -353,13 +664,13 @@ class TestUpdateStreamGoesLive:
|
||||
) -> None:
|
||||
"""Send go-live with stream name and tags."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="live.com",
|
||||
title="Now Streaming",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Live Stream", tags=("gaming",)),
|
||||
stream_config=StreamConfigObservation(name="Live Stream", tags=("gaming",)),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -393,6 +704,7 @@ class TestUpdateStreamGoesLive:
|
||||
"\n"
|
||||
"#gaming"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
async def test_falls_back_when_config_fetch_fails(
|
||||
self,
|
||||
@@ -401,11 +713,11 @@ class TestUpdateStreamGoesLive:
|
||||
) -> None:
|
||||
"""Use domain as name in go-live when config fetch fails."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="live.com",
|
||||
title="Now Streaming",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=None,
|
||||
)
|
||||
@@ -437,6 +749,7 @@ class TestUpdateStreamGoesLive:
|
||||
"\n"
|
||||
"To tune in, visit: https://live.com/"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
|
||||
class TestUpdateStreamBriefOffline:
|
||||
@@ -449,13 +762,13 @@ class TestUpdateStreamBriefOffline:
|
||||
) -> None:
|
||||
"""Skip notification for a brief outage with no title change."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="brief.com",
|
||||
title="Same Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:01:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Brief Stream"),
|
||||
stream_config=StreamConfigObservation(name="Brief Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -480,6 +793,11 @@ class TestUpdateStreamBriefOffline:
|
||||
result = await monitor.update_stream("brief.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 0
|
||||
state = await stream_repo.get_by_domain("brief.com")
|
||||
assert state is not None
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T12:01:00+00:00"
|
||||
|
||||
async def test_title_change_notification(
|
||||
self,
|
||||
@@ -488,13 +806,13 @@ class TestUpdateStreamBriefOffline:
|
||||
) -> None:
|
||||
"""Send a title-change notification for a brief outage with a new title."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="brief.com",
|
||||
title="New Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Brief Stream"),
|
||||
stream_config=StreamConfigObservation(name="Brief Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -525,6 +843,10 @@ class TestUpdateStreamBriefOffline:
|
||||
"\n"
|
||||
"To tune in, visit: https://brief.com/"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
state = await stream_repo.get_by_domain("brief.com")
|
||||
assert state is not None
|
||||
assert state.name == "Brief Stream"
|
||||
|
||||
|
||||
class TestUpdateStreamTitleChange:
|
||||
@@ -537,11 +859,11 @@ class TestUpdateStreamTitleChange:
|
||||
) -> None:
|
||||
"""Use domain as name in title-change when config fails."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=None,
|
||||
)
|
||||
@@ -580,6 +902,7 @@ class TestUpdateStreamTitleChange:
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
async def test_sends_title_change(
|
||||
self,
|
||||
@@ -588,13 +911,13 @@ class TestUpdateStreamTitleChange:
|
||||
) -> None:
|
||||
"""Send a title-change notification when the title changes mid-stream."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Title Stream"),
|
||||
stream_config=StreamConfigObservation(name="Title Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, notification_service = _make_monitor(
|
||||
@@ -634,6 +957,7 @@ class TestUpdateStreamTitleChange:
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
async def test_sends_go_live_when_last_notification_before_offline(
|
||||
self,
|
||||
@@ -642,13 +966,13 @@ class TestUpdateStreamTitleChange:
|
||||
) -> None:
|
||||
"""Send go-live instead of title-change after an offline gap."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="title.com",
|
||||
title="Updated Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Title Stream"),
|
||||
stream_config=StreamConfigObservation(name="Title Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, notification_service = _make_monitor(
|
||||
@@ -686,6 +1010,7 @@ class TestUpdateStreamTitleChange:
|
||||
"\n"
|
||||
"To tune in, visit: https://title.com/"
|
||||
)
|
||||
assert owncast.config_call_count == 1
|
||||
|
||||
|
||||
class TestUpdateStreamGoesOffline:
|
||||
@@ -698,12 +1023,12 @@ class TestUpdateStreamGoesOffline:
|
||||
) -> None:
|
||||
"""Record the offline time in the cache when a stream goes offline."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="offline.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:01:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Offline Stream"),
|
||||
stream_config=StreamConfigObservation(name="Offline Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
@@ -730,6 +1055,180 @@ class TestUpdateStreamGoesOffline:
|
||||
assert result is True
|
||||
assert before <= monitor.offline_timer_cache["offline.com"] <= after
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 0
|
||||
state = await stream_repo.get_by_domain("offline.com")
|
||||
assert state is not None
|
||||
assert state.online is False
|
||||
assert state.name == "Test Stream"
|
||||
|
||||
|
||||
class TestUpdateStreamNoStateChange:
|
||||
"""Offline stream persistence and config refresh behavior."""
|
||||
|
||||
async def test_does_not_fetch_config_for_unchanged_non_hourly_state(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Do not fetch config when state is unchanged outside hourly refresh."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=_stream_observation(
|
||||
domain="stable.com",
|
||||
title="Same Title",
|
||||
observed_at="2026-01-01T12:01:00+00:00",
|
||||
),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline with same status timestamp and title.
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="stable.com",
|
||||
title="Same Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("fail.com")
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
# Config should not have been fetched since this was not an hourly refresh.
|
||||
assert owncast.config_call_count == 0
|
||||
|
||||
async def test_refreshes_config_at_top_of_hour(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Refresh and persist stream config during the hourly refresh window."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=_stream_observation(
|
||||
domain="stable.com",
|
||||
title="Same Title",
|
||||
observed_at="2026-01-01T12:00:30+00:00",
|
||||
),
|
||||
stream_config=StreamConfigObservation(name="Updated Stream"),
|
||||
)
|
||||
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="stable.com",
|
||||
name="Old Stream",
|
||||
title="Same Title",
|
||||
status_since="2026-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("stable.com")
|
||||
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 1
|
||||
state = await stream_repo.get_by_domain("stable.com")
|
||||
assert state is not None
|
||||
assert state.name == "Updated Stream"
|
||||
assert state.title == "Same Title"
|
||||
assert state.status_since == "2026-01-01T10:00:00+00:00"
|
||||
|
||||
async def test_saves_offline_title_change(
|
||||
self,
|
||||
stream_repo: StreamRepository,
|
||||
subscription_repo: SubscriptionRepository,
|
||||
) -> None:
|
||||
"""Save title-only changes while the stream remains offline."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=_stream_observation(
|
||||
domain="stable.com",
|
||||
title="New Offline Title",
|
||||
observed_at="2026-01-01T12:30:00+00:00",
|
||||
),
|
||||
)
|
||||
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="stable.com",
|
||||
title="Old Offline Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
result = await monitor.update_stream("stable.com")
|
||||
|
||||
assert result is True
|
||||
assert len(client.sent_messages) == 0
|
||||
assert owncast.config_call_count == 0
|
||||
state = await stream_repo.get_by_domain("stable.com")
|
||||
assert state is not None
|
||||
assert state.title == "New Offline Title"
|
||||
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||
|
||||
|
||||
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=_stream_observation(
|
||||
domain="recover.com",
|
||||
title="Title",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
)
|
||||
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",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
# 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 TestUpdateStreamConnectionFailure:
|
||||
@@ -759,6 +1258,7 @@ class TestUpdateStreamConnectionFailure:
|
||||
|
||||
result = await monitor.update_stream("fail.com")
|
||||
assert result is False
|
||||
assert owncast.config_call_count == 0
|
||||
|
||||
state = await stream_repo.get_by_domain("fail.com")
|
||||
assert state is not None
|
||||
@@ -863,94 +1363,6 @@ class TestCheckCleanupThresholds:
|
||||
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",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _ = _make_monitor(
|
||||
owncast_client=owncast,
|
||||
stream_repo=stream_repo,
|
||||
subscription_repo=subscription_repo,
|
||||
client=client,
|
||||
)
|
||||
|
||||
# Seed as offline with same status timestamp and title.
|
||||
await _seed_stream(
|
||||
stream_repo,
|
||||
subscription_repo,
|
||||
domain="stable.com",
|
||||
title="Same Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
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",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
)
|
||||
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",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
)
|
||||
|
||||
# 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."""
|
||||
|
||||
@@ -1001,16 +1413,16 @@ class TestUpdateAllStreamsMixed:
|
||||
) -> None:
|
||||
"""Count an unhandled exception as a failure without aborting other updates."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="ok.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
observed_at="2026-01-01T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
# Patch get_stream_state to raise for one specific domain
|
||||
original_get_state = owncast.get_stream_state
|
||||
|
||||
async def raising_get_state(domain: str) -> StreamState | None:
|
||||
async def raising_get_state(domain: str) -> StreamStateObservation | None:
|
||||
if domain == "raise.com":
|
||||
msg = "simulated DB error"
|
||||
raise RuntimeError(msg)
|
||||
@@ -1057,13 +1469,13 @@ class TestStreamMonitorMetrics:
|
||||
) -> None:
|
||||
"""Record online stream status gauge."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="live.com",
|
||||
title="Title",
|
||||
online=True,
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Live Stream"),
|
||||
stream_config=StreamConfigObservation(name="Live Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
@@ -1090,12 +1502,12 @@ class TestStreamMonitorMetrics:
|
||||
) -> None:
|
||||
"""Record offline stream status gauge."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="off.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Off Stream"),
|
||||
stream_config=StreamConfigObservation(name="Off Stream"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
@@ -1146,12 +1558,12 @@ class TestStreamMonitorMetrics:
|
||||
) -> None:
|
||||
"""Reset failure counter gauge to zero on successful check."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="recover.com",
|
||||
title="Title",
|
||||
status_since="2026-01-01T12:00:00+00:00",
|
||||
observed_at="2026-01-01T12:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Recover"),
|
||||
stream_config=StreamConfigObservation(name="Recover"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
@@ -1228,12 +1640,12 @@ class TestStreamMonitorMetrics:
|
||||
) -> None:
|
||||
"""Record per-domain subscription counts after update_all_streams."""
|
||||
owncast = _StubOwncastClient(
|
||||
stream_state=StreamState(
|
||||
stream_state=_stream_observation(
|
||||
domain="pop.com",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
observed_at="2026-01-01T00:00:00+00:00",
|
||||
),
|
||||
stream_config=StreamConfig(name="Popular"),
|
||||
stream_config=StreamConfigObservation(name="Popular"),
|
||||
)
|
||||
client = _StubMatrixClient()
|
||||
monitor, _, metrics = _make_monitor_with_metrics(
|
||||
|
||||
@@ -25,13 +25,47 @@ from owncastsentry.types import (
|
||||
AlreadySubscribedError,
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
StreamState,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||
|
||||
|
||||
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 TestDomainify:
|
||||
"""Domain extraction and sanitization from user input."""
|
||||
|
||||
@@ -74,41 +108,6 @@ class TestDomainify:
|
||||
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."""
|
||||
|
||||
@@ -226,9 +225,9 @@ class TestManagerListings:
|
||||
) -> 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.update("beta.example", name="Beta")
|
||||
await stream_repo.create("alpha.example")
|
||||
await stream_repo.update(StreamState(domain="alpha.example", name="Alpha"))
|
||||
await stream_repo.update("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")
|
||||
@@ -253,29 +252,23 @@ class TestManagerListings:
|
||||
"""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",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"offline.example",
|
||||
name="Offline",
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("online.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"online.example",
|
||||
name="Online",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
await stream_repo.create("unknown.example")
|
||||
await stream_repo.update(
|
||||
StreamState(
|
||||
domain="unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
"unknown.example",
|
||||
name="Unknown",
|
||||
online=True,
|
||||
status_since="2026-01-01T00:00:00+00:00",
|
||||
)
|
||||
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||
await stream_repo.increment_failure_counter("unknown.example")
|
||||
|
||||
+50
-32
@@ -18,6 +18,7 @@ from dataclasses import FrozenInstanceError
|
||||
from datetime import UTC, datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
import time_machine
|
||||
|
||||
from owncastsentry.types import (
|
||||
_MAX_INSTANCE_TITLE_LENGTH,
|
||||
@@ -29,8 +30,9 @@ from owncastsentry.types import (
|
||||
InvalidOwncastInstanceError,
|
||||
NotSubscribedError,
|
||||
RoomSubscription,
|
||||
StreamConfig,
|
||||
StreamConfigObservation,
|
||||
StreamState,
|
||||
StreamStateObservation,
|
||||
StreamStatus,
|
||||
SubscriptionError,
|
||||
UpdateResult,
|
||||
@@ -140,29 +142,29 @@ class TestFormatStatusSince:
|
||||
assert format_status_since(timestamp) == "2026-01-01T12:34:56+00:00"
|
||||
|
||||
|
||||
class TestStreamStateFromApiResponse:
|
||||
"""StreamState construction from an API response dictionary."""
|
||||
class TestStreamStateObservationFromApiResponse:
|
||||
"""StreamStateObservation construction from an API response dictionary."""
|
||||
|
||||
def test_typical_response(self) -> None:
|
||||
"""Populate API-derived fields from a complete stream state response."""
|
||||
"""Populate API-derived fields from a complete status response."""
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
response = {
|
||||
"streamTitle": "My Stream",
|
||||
"online": True,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com", observed_at)
|
||||
assert state.domain == "example.com"
|
||||
assert state.title == "My Stream"
|
||||
assert state.online is True
|
||||
assert state.status_since == "2026-01-01T00:00:01+00:00"
|
||||
assert state.name is None
|
||||
assert state.failure_counter == 0
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert observation.domain == "example.com"
|
||||
assert observation.title == "My Stream"
|
||||
assert observation.online is True
|
||||
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_missing_required_field_raises(self) -> None:
|
||||
"""Reject API responses without required stream state fields."""
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamState.from_api_response({}, "bare.example.com", observed_at)
|
||||
StreamStateObservation.from_api_response({}, "bare.example.com")
|
||||
|
||||
def test_offline_response(self) -> None:
|
||||
"""Populate offline state from a status response."""
|
||||
@@ -171,9 +173,12 @@ class TestStreamStateFromApiResponse:
|
||||
"streamTitle": "Offline Stream",
|
||||
"online": False,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com", observed_at)
|
||||
assert state.online is False
|
||||
assert state.status_since == "2026-01-01T00:00:01+00:00"
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert observation.online is False
|
||||
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_title_truncation(self) -> None:
|
||||
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
|
||||
@@ -183,9 +188,12 @@ class TestStreamStateFromApiResponse:
|
||||
"streamTitle": long_title,
|
||||
"online": True,
|
||||
}
|
||||
state = StreamState.from_api_response(response, "example.com", observed_at)
|
||||
assert len(state.title) == _MAX_STREAM_TITLE_LENGTH
|
||||
assert state.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
observation = StreamStateObservation.from_api_response(
|
||||
response, "example.com"
|
||||
)
|
||||
assert len(observation.title) == _MAX_STREAM_TITLE_LENGTH
|
||||
assert observation.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
@@ -196,7 +204,6 @@ class TestStreamStateFromApiResponse:
|
||||
)
|
||||
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||
"""Reject stream state responses with malformed field types."""
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
response: dict[str, object] = {
|
||||
"streamTitle": "My Stream",
|
||||
"online": True,
|
||||
@@ -204,7 +211,7 @@ class TestStreamStateFromApiResponse:
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamState.from_api_response(response, "example.com", observed_at)
|
||||
StreamStateObservation.from_api_response(response, "example.com")
|
||||
|
||||
|
||||
class TestStreamStateFromDbRow:
|
||||
@@ -247,19 +254,22 @@ class TestStreamStateFromDbRow:
|
||||
assert state.failure_counter == 0
|
||||
|
||||
|
||||
class TestStreamConfigFromApiResponse:
|
||||
"""StreamConfig construction from an API response dictionary."""
|
||||
class TestStreamConfigObservationFromApiResponse:
|
||||
"""StreamConfigObservation 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)
|
||||
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||
with time_machine.travel(observed_at, tick=False):
|
||||
config = StreamConfigObservation.from_api_response(response)
|
||||
assert config.name == "My Instance"
|
||||
assert config.tags == ("gaming", "music")
|
||||
assert config.observed_at == "2026-01-01T00:00:01+00:00"
|
||||
|
||||
def test_missing_keys_defaults(self) -> None:
|
||||
"""Use defaults when name and tags keys are missing."""
|
||||
config = StreamConfig.from_api_response({})
|
||||
config = StreamConfigObservation.from_api_response({})
|
||||
assert config.name == ""
|
||||
assert config.tags == ()
|
||||
|
||||
@@ -267,7 +277,7 @@ class TestStreamConfigFromApiResponse:
|
||||
"""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)
|
||||
config = StreamConfigObservation.from_api_response(response)
|
||||
assert len(config.name) == _MAX_INSTANCE_TITLE_LENGTH
|
||||
assert config.name == "B" * _MAX_INSTANCE_TITLE_LENGTH
|
||||
|
||||
@@ -275,7 +285,7 @@ class TestStreamConfigFromApiResponse:
|
||||
"""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)
|
||||
config = StreamConfigObservation.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"
|
||||
@@ -294,7 +304,7 @@ class TestStreamConfigFromApiResponse:
|
||||
response[field] = value
|
||||
|
||||
with pytest.raises(InvalidApiResponseError):
|
||||
StreamConfig.from_api_response(response)
|
||||
StreamConfigObservation.from_api_response(response)
|
||||
|
||||
|
||||
class TestValueTypeImmutability:
|
||||
@@ -306,15 +316,23 @@ class TestValueTypeImmutability:
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
state.title = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_observation_is_immutable(self) -> None:
|
||||
"""StreamStateObservation cannot be mutated in place."""
|
||||
observation = StreamStateObservation(
|
||||
domain="stream.example",
|
||||
)
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
observation.title = "Changed" # type: ignore[misc]
|
||||
|
||||
def test_stream_config_is_immutable(self) -> None:
|
||||
"""StreamConfig cannot be mutated in place."""
|
||||
config = StreamConfig(name="Stream")
|
||||
"""StreamConfigObservation cannot be mutated in place."""
|
||||
config = StreamConfigObservation(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",))
|
||||
"""StreamConfigObservation tags are stored in an immutable tuple."""
|
||||
config = StreamConfigObservation(name="Stream", tags=("gaming",))
|
||||
assert config.tags == ("gaming",)
|
||||
|
||||
def test_update_result_is_immutable(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user