Refactored stream observations and config refresh handling.
CI / Formatting (push) Successful in 6s
CI / Linting (push) Successful in 6s
CI / Tests (push) Successful in 27s
CI / Type Checking (push) Successful in 23s
CI / Spelling (push) Successful in 19s

This commit is contained in:
2026-05-21 19:44:29 -04:00
parent c837b916c0
commit 38e91add32
14 changed files with 1503 additions and 512 deletions
+6 -6
View File
@@ -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
View File
@@ -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")
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+48 -55
View File
@@ -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
View File
@@ -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: